aru-code 0.56.0__tar.gz → 0.58.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.
Files changed (216) hide show
  1. {aru_code-0.56.0/aru_code.egg-info → aru_code-0.58.0}/PKG-INFO +1 -1
  2. aru_code-0.58.0/aru/__init__.py +1 -0
  3. aru_code-0.58.0/aru/auth.py +93 -0
  4. {aru_code-0.56.0 → aru_code-0.58.0}/aru/commands.py +385 -0
  5. {aru_code-0.56.0 → aru_code-0.58.0}/aru/providers.py +84 -1
  6. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/plan_mode.py +13 -1
  7. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/app.py +117 -5
  8. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/text_input.py +3 -0
  9. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/slash_bridge.py +11 -1
  10. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/ui.py +53 -1
  11. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/completer.py +1 -0
  12. {aru_code-0.56.0 → aru_code-0.58.0}/aru/ui.py +13 -2
  13. {aru_code-0.56.0 → aru_code-0.58.0/aru_code.egg-info}/PKG-INFO +1 -1
  14. {aru_code-0.56.0 → aru_code-0.58.0}/aru_code.egg-info/SOURCES.txt +4 -0
  15. {aru_code-0.56.0 → aru_code-0.58.0}/pyproject.toml +1 -1
  16. aru_code-0.58.0/tests/test_auth_store.py +143 -0
  17. aru_code-0.58.0/tests/test_connect_command.py +256 -0
  18. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_plan_mode_refactor.py +54 -0
  19. aru_code-0.58.0/tests/test_tui_connect_wiring.py +42 -0
  20. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_permission_flow.py +111 -0
  21. aru_code-0.56.0/aru/__init__.py +0 -1
  22. {aru_code-0.56.0 → aru_code-0.58.0}/LICENSE +0 -0
  23. {aru_code-0.56.0 → aru_code-0.58.0}/README.md +0 -0
  24. {aru_code-0.56.0 → aru_code-0.58.0}/aru/_debug/__init__.py +0 -0
  25. {aru_code-0.56.0 → aru_code-0.58.0}/aru/_debug/analyze_trace.py +0 -0
  26. {aru_code-0.56.0 → aru_code-0.58.0}/aru/_debug/loop_tracer.py +0 -0
  27. {aru_code-0.56.0 → aru_code-0.58.0}/aru/agent_factory.py +0 -0
  28. {aru_code-0.56.0 → aru_code-0.58.0}/aru/agents/__init__.py +0 -0
  29. {aru_code-0.56.0 → aru_code-0.58.0}/aru/agents/base.py +0 -0
  30. {aru_code-0.56.0 → aru_code-0.58.0}/aru/agents/catalog.py +0 -0
  31. {aru_code-0.56.0 → aru_code-0.58.0}/aru/agents/planner.py +0 -0
  32. {aru_code-0.56.0 → aru_code-0.58.0}/aru/cache_patch.py +0 -0
  33. {aru_code-0.56.0 → aru_code-0.58.0}/aru/checkpoints.py +0 -0
  34. {aru_code-0.56.0 → aru_code-0.58.0}/aru/cli.py +0 -0
  35. {aru_code-0.56.0 → aru_code-0.58.0}/aru/completers.py +0 -0
  36. {aru_code-0.56.0 → aru_code-0.58.0}/aru/config.py +0 -0
  37. {aru_code-0.56.0 → aru_code-0.58.0}/aru/context.py +0 -0
  38. {aru_code-0.56.0 → aru_code-0.58.0}/aru/display.py +0 -0
  39. {aru_code-0.56.0 → aru_code-0.58.0}/aru/doom_loop.py +0 -0
  40. {aru_code-0.56.0 → aru_code-0.58.0}/aru/events.py +0 -0
  41. {aru_code-0.56.0 → aru_code-0.58.0}/aru/format/__init__.py +0 -0
  42. {aru_code-0.56.0 → aru_code-0.58.0}/aru/format/manager.py +0 -0
  43. {aru_code-0.56.0 → aru_code-0.58.0}/aru/format/runner.py +0 -0
  44. {aru_code-0.56.0 → aru_code-0.58.0}/aru/history_blocks.py +0 -0
  45. {aru_code-0.56.0 → aru_code-0.58.0}/aru/lsp/__init__.py +0 -0
  46. {aru_code-0.56.0 → aru_code-0.58.0}/aru/lsp/client.py +0 -0
  47. {aru_code-0.56.0 → aru_code-0.58.0}/aru/lsp/manager.py +0 -0
  48. {aru_code-0.56.0 → aru_code-0.58.0}/aru/lsp/protocol.py +0 -0
  49. {aru_code-0.56.0 → aru_code-0.58.0}/aru/memory/__init__.py +0 -0
  50. {aru_code-0.56.0 → aru_code-0.58.0}/aru/memory/extractor.py +0 -0
  51. {aru_code-0.56.0 → aru_code-0.58.0}/aru/memory/loader.py +0 -0
  52. {aru_code-0.56.0 → aru_code-0.58.0}/aru/memory/store.py +0 -0
  53. {aru_code-0.56.0 → aru_code-0.58.0}/aru/permissions.py +0 -0
  54. {aru_code-0.56.0 → aru_code-0.58.0}/aru/plugin_cache.py +0 -0
  55. {aru_code-0.56.0 → aru_code-0.58.0}/aru/plugins/__init__.py +0 -0
  56. {aru_code-0.56.0 → aru_code-0.58.0}/aru/plugins/custom_tools.py +0 -0
  57. {aru_code-0.56.0 → aru_code-0.58.0}/aru/plugins/hooks.py +0 -0
  58. {aru_code-0.56.0 → aru_code-0.58.0}/aru/plugins/manager.py +0 -0
  59. {aru_code-0.56.0 → aru_code-0.58.0}/aru/plugins/tool_api.py +0 -0
  60. {aru_code-0.56.0 → aru_code-0.58.0}/aru/runner.py +0 -0
  61. {aru_code-0.56.0 → aru_code-0.58.0}/aru/runtime.py +0 -0
  62. {aru_code-0.56.0 → aru_code-0.58.0}/aru/select.py +0 -0
  63. {aru_code-0.56.0 → aru_code-0.58.0}/aru/session.py +0 -0
  64. {aru_code-0.56.0 → aru_code-0.58.0}/aru/sinks.py +0 -0
  65. {aru_code-0.56.0 → aru_code-0.58.0}/aru/streaming.py +0 -0
  66. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tool_policy.py +0 -0
  67. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/__init__.py +0 -0
  68. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/_diff.py +0 -0
  69. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/_shared.py +0 -0
  70. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/apply_patch.py +0 -0
  71. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/apply_patch_prompt.txt +0 -0
  72. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/ast_tools.py +0 -0
  73. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/codebase.py +0 -0
  74. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/delegate.py +0 -0
  75. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/delegate_prompt.txt +0 -0
  76. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/file_ops.py +0 -0
  77. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/gitignore.py +0 -0
  78. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/lsp.py +0 -0
  79. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/mcp_client.py +0 -0
  80. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/memory_tool.py +0 -0
  81. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/ranker.py +0 -0
  82. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/registry.py +0 -0
  83. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/search.py +0 -0
  84. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/shell.py +0 -0
  85. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/skill.py +0 -0
  86. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/tasklist.py +0 -0
  87. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/web.py +0 -0
  88. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tools/worktree.py +0 -0
  89. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/__init__.py +0 -0
  90. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/log_bridge.py +0 -0
  91. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/notifications.py +0 -0
  92. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/sanitize.py +0 -0
  93. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/__init__.py +0 -0
  94. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/choice.py +0 -0
  95. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/confirm.py +0 -0
  96. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/keymap.py +0 -0
  97. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/search.py +0 -0
  98. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/screens/session_picker.py +0 -0
  99. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/sinks.py +0 -0
  100. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/themes.py +0 -0
  101. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/__init__.py +0 -0
  102. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/chat.py +0 -0
  103. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/context_pane.py +0 -0
  104. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/file_link.py +0 -0
  105. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/header.py +0 -0
  106. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/inline_choice.py +0 -0
  107. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/loaded_pane.py +0 -0
  108. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/prompt_area.py +0 -0
  109. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/prompt_queue.py +0 -0
  110. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/status.py +0 -0
  111. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/subagent_panel.py +0 -0
  112. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/tasklist_panel.py +0 -0
  113. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/thinking.py +0 -0
  114. {aru_code-0.56.0 → aru_code-0.58.0}/aru/tui/widgets/tools.py +0 -0
  115. {aru_code-0.56.0 → aru_code-0.58.0}/aru_code.egg-info/dependency_links.txt +0 -0
  116. {aru_code-0.56.0 → aru_code-0.58.0}/aru_code.egg-info/entry_points.txt +0 -0
  117. {aru_code-0.56.0 → aru_code-0.58.0}/aru_code.egg-info/requires.txt +0 -0
  118. {aru_code-0.56.0 → aru_code-0.58.0}/aru_code.egg-info/top_level.txt +0 -0
  119. {aru_code-0.56.0 → aru_code-0.58.0}/setup.cfg +0 -0
  120. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_agents_base.py +0 -0
  121. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_agents_md_coverage.py +0 -0
  122. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_apply_patch.py +0 -0
  123. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_async_tool_permission.py +0 -0
  124. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cache_patch_metrics.py +0 -0
  125. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cache_patch_stop_reason.py +0 -0
  126. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_catalog.py +0 -0
  127. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_chat_scrollable.py +0 -0
  128. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_checkpoints.py +0 -0
  129. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli.py +0 -0
  130. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_advanced.py +0 -0
  131. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_base.py +0 -0
  132. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_completers.py +0 -0
  133. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_new.py +0 -0
  134. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_run_cli.py +0 -0
  135. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_session.py +0 -0
  136. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cli_shell.py +0 -0
  137. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_codebase.py +0 -0
  138. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_confabulation_regression.py +0 -0
  139. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_config.py +0 -0
  140. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_context.py +0 -0
  141. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_context_pane.py +0 -0
  142. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_cwd_awareness.py +0 -0
  143. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_delegate.py +0 -0
  144. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_doom_loop.py +0 -0
  145. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_events_backward_compat.py +0 -0
  146. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_events_schema.py +0 -0
  147. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_fork_ctx_concurrency.py +0 -0
  148. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_format.py +0 -0
  149. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_gitignore.py +0 -0
  150. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_guardrails_scenarios.py +0 -0
  151. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_invoke_skill.py +0 -0
  152. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_invoked_skills.py +0 -0
  153. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_loaded_pane_path.py +0 -0
  154. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_lsp.py +0 -0
  155. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_lsp_rename.py +0 -0
  156. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_main.py +0 -0
  157. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_markdown_to_text.py +0 -0
  158. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_mcp_client.py +0 -0
  159. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_mcp_health.py +0 -0
  160. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_memory.py +0 -0
  161. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_memory_tool.py +0 -0
  162. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_microcompact.py +0 -0
  163. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_permission_timeout_suspension.py +0 -0
  164. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_permissions.py +0 -0
  165. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_plugin_cache.py +0 -0
  166. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_plugin_errors.py +0 -0
  167. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_plugin_hooks_v2.py +0 -0
  168. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_plugins.py +0 -0
  169. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_providers.py +0 -0
  170. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_ranker.py +0 -0
  171. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_reasoning.py +0 -0
  172. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_runner_interrupt.py +0 -0
  173. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_runner_recovery.py +0 -0
  174. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_runtime.py +0 -0
  175. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_select.py +0 -0
  176. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_session_free_cost.py +0 -0
  177. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_skill_disallowed_tools.py +0 -0
  178. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_status_breakdown.py +0 -0
  179. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_status_cost.py +0 -0
  180. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_streaming_sink.py +0 -0
  181. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_subagent_tool_events.py +0 -0
  182. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tasklist.py +0 -0
  183. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_thread_tool_timeout.py +0 -0
  184. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tool_policy.py +0 -0
  185. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_truncation_marker.py +0 -0
  186. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_app_boot.py +0 -0
  187. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_bindings.py +0 -0
  188. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_bus_flow.py +0 -0
  189. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_chat.py +0 -0
  190. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_chat_adversarial.py +0 -0
  191. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_completer.py +0 -0
  192. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_completer_dynamic.py +0 -0
  193. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_copy.py +0 -0
  194. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_error_display.py +0 -0
  195. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_file_link.py +0 -0
  196. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_input_behaviour.py +0 -0
  197. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_layer12_recovery.py +0 -0
  198. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_layer13_recovery.py +0 -0
  199. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_mention_expand.py +0 -0
  200. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_modals.py +0 -0
  201. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_mode_cycle.py +0 -0
  202. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_native_selection.py +0 -0
  203. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_plan_task_render.py +0 -0
  204. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_prompt_queue.py +0 -0
  205. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_shell_bang.py +0 -0
  206. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_sidebar_toggle.py +0 -0
  207. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_slash_bridge.py +0 -0
  208. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_slash_model.py +0 -0
  209. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_snapshot_smoke.py +0 -0
  210. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_subagent_panel.py +0 -0
  211. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_theme.py +0 -0
  212. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_thinking_and_boot.py +0 -0
  213. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_tui_widgets_visual.py +0 -0
  214. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_ui_adapter.py +0 -0
  215. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_worktree.py +0 -0
  216. {aru_code-0.56.0 → aru_code-0.58.0}/tests/test_worktree_session_restore.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.56.0
3
+ Version: 0.58.0
4
4
  Summary: A Claude Code clone built with Agno agents
5
5
  Author-email: Estevao <estevaofon@gmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.58.0"
@@ -0,0 +1,93 @@
1
+ """Credential store for provider API keys (OpenCode-parity ``auth.json``).
2
+
3
+ ``/connect`` writes here so users no longer have to hand-edit ``aru.json``
4
+ to wire up a provider. Mirrors OpenCode's ``auth.json``: a flat
5
+ ``{ "<provider>": <info> }`` map persisted under the user's home, written
6
+ with ``0600`` permissions so the key isn't world-readable.
7
+
8
+ Schema (``info``) — a tagged union on ``type`` so future auth methods
9
+ (OAuth, well-known) can slot in beside the current API-key path:
10
+
11
+ {"type": "api", "key": "sk-...", # built-in provider
12
+ "base_url": "...", "name": "...", # extra fields for a
13
+ "provider_type": "openai", "default_model": "...", # custom provider
14
+ "context_limit": 128000}
15
+ {"type": "local", "base_url": "http://..."} # keyless (e.g. Ollama)
16
+
17
+ Consumption lives in :func:`aru.providers.apply_stored_credentials`, which
18
+ layers these onto the in-memory provider registry at startup (and again
19
+ right after ``/connect``) so a stored key takes precedence over the
20
+ provider's ``api_key_env``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import os
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ logger = logging.getLogger("aru.auth")
32
+
33
+
34
+ def auth_path() -> Path:
35
+ """Absolute path to the credential file (``~/.aru/auth.json``)."""
36
+ return Path.home() / ".aru" / "auth.json"
37
+
38
+
39
+ def load_auth() -> dict[str, dict[str, Any]]:
40
+ """Return the full credential map, or ``{}`` when missing/unreadable."""
41
+ path = auth_path()
42
+ if not path.is_file():
43
+ return {}
44
+ try:
45
+ data = json.loads(path.read_text(encoding="utf-8"))
46
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
47
+ logger.warning("Failed to read %s: %s", path, exc)
48
+ return {}
49
+ if not isinstance(data, dict):
50
+ return {}
51
+ # Drop any malformed (non-dict) entries defensively.
52
+ return {k: v for k, v in data.items() if isinstance(v, dict)}
53
+
54
+
55
+ def get_credential(provider_key: str) -> dict[str, Any] | None:
56
+ """Return the stored credential for ``provider_key`` or ``None``."""
57
+ return load_auth().get(provider_key)
58
+
59
+
60
+ def _write_auth(data: dict[str, dict[str, Any]]) -> None:
61
+ """Write the credential map atomically-ish with ``0600`` perms."""
62
+ path = auth_path()
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ tmp = path.with_suffix(".json.tmp")
65
+ tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
66
+ # Lock down before the rename so the secret is never briefly group/other
67
+ # readable. chmod is a partial no-op on Windows but harmless.
68
+ try:
69
+ os.chmod(tmp, 0o600)
70
+ except OSError:
71
+ pass
72
+ os.replace(tmp, path)
73
+ try:
74
+ os.chmod(path, 0o600)
75
+ except OSError:
76
+ pass
77
+
78
+
79
+ def set_credential(provider_key: str, info: dict[str, Any]) -> None:
80
+ """Store (or replace) the credential for ``provider_key``."""
81
+ data = load_auth()
82
+ data[provider_key] = info
83
+ _write_auth(data)
84
+
85
+
86
+ def remove_credential(provider_key: str) -> bool:
87
+ """Delete the credential for ``provider_key``. Returns ``True`` if removed."""
88
+ data = load_auth()
89
+ if provider_key not in data:
90
+ return False
91
+ del data[provider_key]
92
+ _write_auth(data)
93
+ return True
@@ -15,6 +15,7 @@ from aru.display import console
15
15
  SLASH_COMMANDS = [
16
16
  ("/help", "Show help and available commands", "/help"),
17
17
  ("/plan", "Create an implementation plan", "/plan <task>"),
18
+ ("/connect", "Connect a provider — store an API key", "/connect [provider|list|logout]"),
18
19
  ("/model", "Switch model/provider", "/model [provider/model]"),
19
20
  ("/reasoning", "Set reasoning effort for this session", "/reasoning [low|medium|high|max|off|clear]"),
20
21
  ("/sessions", "List recent sessions", "/sessions"),
@@ -79,6 +80,389 @@ def ask_yes_no(prompt: str) -> bool:
79
80
  return False
80
81
 
81
82
 
83
+ # ---------------------------------------------------------------------------
84
+ # /connect — interactive provider connection (OpenCode `auth login` parity)
85
+ # ---------------------------------------------------------------------------
86
+
87
+ # Where to grab an API key, shown as a hint before the key prompt. Keyed by
88
+ # built-in provider id. Providers absent here just skip the hint.
89
+ _PROVIDER_KEY_HINTS: dict[str, str] = {
90
+ "anthropic": "Create a key at https://console.anthropic.com/settings/keys",
91
+ "openai": "Create a key at https://platform.openai.com/api-keys",
92
+ "openrouter": "Create a key at https://openrouter.ai/keys",
93
+ "groq": "Create a key at https://console.groq.com/keys",
94
+ "deepseek": "Create a key at https://platform.deepseek.com/api_keys",
95
+ "ollama": "Local provider — no API key needed.",
96
+ }
97
+
98
+ # Display order for the provider menu (most common first); anything not
99
+ # listed is appended afterwards in registry order.
100
+ _PROVIDER_MENU_ORDER = ["anthropic", "openai", "openrouter", "groq", "deepseek", "ollama"]
101
+
102
+ _OTHER_SENTINEL = "__other__"
103
+
104
+
105
+ def _resolve_connect_ui():
106
+ """Return the active ``ctx.ui`` adapter, falling back to a REPL one."""
107
+ from aru.runtime import get_ctx
108
+ try:
109
+ ctx = get_ctx()
110
+ except LookupError:
111
+ ctx = None
112
+ if ctx is not None:
113
+ from aru.permissions import _resolve_ui
114
+ return _resolve_ui(ctx)
115
+ from aru.ui import ReplUI
116
+ return ReplUI()
117
+
118
+
119
+ def _mask_key(key: str) -> str:
120
+ """Mask an API key for display: keep a short head/tail, hide the middle."""
121
+ if not key:
122
+ return "(empty)"
123
+ if len(key) <= 8:
124
+ return "****"
125
+ return f"{key[:4]}…{key[-4:]}"
126
+
127
+
128
+ def handle_connect_command(args: str, session=None):
129
+ """``/connect`` — connect to an LLM provider by storing an API key.
130
+
131
+ OpenCode-parity ``auth login`` flow: pick a provider, paste a key, then
132
+ pick a model — all in one go. The credential is persisted to
133
+ ``~/.aru/auth.json`` and live immediately (no ``aru.json`` editing) and
134
+ the chosen model becomes the session model. All interaction goes through
135
+ ``ctx.ui`` so the same handler drives both the TUI (modals) and the REPL.
136
+
137
+ Subcommands:
138
+ ``/connect`` Interactive: select a provider, enter a key.
139
+ ``/connect <provider>`` Skip selection; connect that provider.
140
+ ``/connect list`` Show stored credentials + active env vars.
141
+ ``/connect logout [p]`` Remove a stored credential.
142
+
143
+ Returns the new ``model_ref`` string when the user opts to switch models
144
+ after connecting (so the caller can sync the UI), else ``None``.
145
+ """
146
+ ui = _resolve_connect_ui()
147
+ arg = (args or "").strip()
148
+ parts = arg.split(None, 1)
149
+ sub = parts[0].lower() if parts else ""
150
+ rest = parts[1].strip() if len(parts) > 1 else ""
151
+
152
+ if sub == "list":
153
+ _connect_list(ui)
154
+ return None
155
+ if sub in ("logout", "disconnect", "remove"):
156
+ _connect_logout(ui, rest)
157
+ return None
158
+ return _connect_login(ui, arg, session)
159
+
160
+
161
+ def _provider_menu() -> list[tuple[str, str]]:
162
+ """Build ``(key, label)`` provider menu entries in display order."""
163
+ from aru.providers import list_providers
164
+
165
+ providers = list_providers()
166
+ ordered_keys = [k for k in _PROVIDER_MENU_ORDER if k in providers]
167
+ ordered_keys += [k for k in providers if k not in ordered_keys]
168
+ entries: list[tuple[str, str]] = []
169
+ for k in ordered_keys:
170
+ p = providers[k]
171
+ entries.append((k, f"{p.name} ({k})"))
172
+ return entries
173
+
174
+
175
+ def _connect_login(ui, preselect: str, session):
176
+ """Provider selection → key entry → store → optional model switch."""
177
+ from aru import auth
178
+ from aru.providers import apply_stored_credentials, get_provider, list_providers
179
+
180
+ entries = _provider_menu()
181
+
182
+ # ── 1. Resolve which provider to connect ─────────────────────────────
183
+ provider_key: str | None = None
184
+ if preselect:
185
+ low = preselect.lower()
186
+ if low in ("other", "custom"):
187
+ provider_key = _OTHER_SENTINEL
188
+ else:
189
+ providers = list_providers()
190
+ if low in providers:
191
+ provider_key = low
192
+ else:
193
+ # Match by display name (case-insensitive).
194
+ for k, p in providers.items():
195
+ if p.name.lower() == low:
196
+ provider_key = k
197
+ break
198
+ if provider_key is None:
199
+ ui.notify(
200
+ f"Unknown provider '{preselect}'. Run /connect with no "
201
+ "argument to pick from the list.",
202
+ "warn",
203
+ )
204
+ return None
205
+ else:
206
+ labels = [label for _, label in entries]
207
+ labels.append("Other (custom OpenAI-compatible endpoint)")
208
+ idx = ui.ask_choice(
209
+ labels,
210
+ title="Connect a provider — select one",
211
+ cancel_value=None,
212
+ )
213
+ if idx is None:
214
+ ui.notify("Connect cancelled.", "warn")
215
+ return None
216
+ provider_key = _OTHER_SENTINEL if idx == len(entries) else entries[idx][0]
217
+
218
+ # ── 2. Gather provider details + the credential ──────────────────────
219
+ if provider_key == _OTHER_SENTINEL:
220
+ return _connect_custom(ui, session)
221
+
222
+ provider = get_provider(provider_key)
223
+ display_name = provider.name if provider else provider_key
224
+ hint = _PROVIDER_KEY_HINTS.get(provider_key)
225
+ if hint:
226
+ ui.print(hint)
227
+
228
+ # Keyless local providers (Ollama) — store a base URL instead of a key.
229
+ if provider is not None and not provider.api_key_env:
230
+ default_url = provider.base_url or "http://localhost:11434"
231
+ base_url = ui.ask_text(
232
+ f"Base URL for {display_name}:", default=default_url
233
+ ).strip()
234
+ if not base_url:
235
+ base_url = default_url
236
+ auth.set_credential(provider_key, {"type": "local", "base_url": base_url})
237
+ apply_stored_credentials()
238
+ ui.print(f"Connected {display_name} at {base_url}.")
239
+ else:
240
+ key = ui.ask_text(
241
+ f"Enter your API key for {display_name}:", password=True
242
+ ).strip()
243
+ if not key:
244
+ ui.notify("Connect cancelled — no key entered.", "warn")
245
+ return None
246
+ auth.set_credential(provider_key, {"type": "api", "key": key})
247
+ apply_stored_credentials()
248
+ ui.print(f"Connected {display_name} — key stored in {auth.auth_path()}.")
249
+
250
+ return _select_model(ui, provider_key, session)
251
+
252
+
253
+ def _connect_custom(ui, session):
254
+ """Connect a custom OpenAI-compatible provider (the 'Other' path)."""
255
+ import re
256
+
257
+ from aru import auth
258
+ from aru.providers import apply_stored_credentials
259
+
260
+ pid = ui.ask_text(
261
+ "Provider id (lowercase letters, digits, hyphens):"
262
+ ).strip().lower()
263
+ if not pid:
264
+ ui.notify("Connect cancelled.", "warn")
265
+ return None
266
+ if not re.fullmatch(r"[a-z0-9-]+", pid):
267
+ ui.notify("Invalid id — use only a-z, 0-9 and hyphens.", "error")
268
+ return None
269
+
270
+ base_url = ui.ask_text(
271
+ "Base URL (OpenAI-compatible, e.g. https://api.example.com/v1):"
272
+ ).strip()
273
+ if not base_url:
274
+ ui.notify("Connect cancelled — a base URL is required.", "warn")
275
+ return None
276
+
277
+ name = ui.ask_text("Display name (optional):", default=pid).strip() or pid
278
+ default_model = ui.ask_text("Default model id (optional):").strip()
279
+ key = ui.ask_text(f"Enter your API key for {name}:", password=True).strip()
280
+ if not key:
281
+ ui.notify("Connect cancelled — no key entered.", "warn")
282
+ return None
283
+
284
+ info = {
285
+ "type": "api",
286
+ "key": key,
287
+ "base_url": base_url,
288
+ "name": name,
289
+ "provider_type": "openai",
290
+ }
291
+ if default_model:
292
+ info["default_model"] = default_model
293
+ auth.set_credential(pid, info)
294
+ apply_stored_credentials()
295
+ ui.print(f"Connected {name} ({pid}) — key stored in {auth.auth_path()}.")
296
+ # The custom setup already asked for a model id — honour it directly
297
+ # instead of re-prompting. Otherwise offer the (free-text) selector.
298
+ if default_model and session is not None:
299
+ session.model_ref = f"{pid}/{default_model}"
300
+ ui.print(f"Model set to {pid}/{default_model}.")
301
+ return f"{pid}/{default_model}"
302
+ return _select_model(ui, pid, session)
303
+
304
+
305
+ _CUSTOM_MODEL_LABEL = "Enter a model id manually…"
306
+
307
+
308
+ def _list_provider_models(provider) -> list[str]:
309
+ """Return the provider's model names, deduped by underlying API id.
310
+
311
+ The registry carries both clean aliases and dated ids that map to the
312
+ same model (e.g. ``claude-sonnet-4-5`` and ``claude-sonnet-4-5-20250929``).
313
+ Keep the first occurrence — the clean alias — so the picker stays tidy.
314
+ """
315
+ seen_ids: set[str] = set()
316
+ out: list[str] = []
317
+ for name, cfg in provider.models.items():
318
+ mid = cfg.get("id", name) if isinstance(cfg, dict) else name
319
+ if mid in seen_ids:
320
+ continue
321
+ seen_ids.add(mid)
322
+ out.append(name)
323
+ return out
324
+
325
+
326
+ def _model_id_for(provider, name: str) -> str:
327
+ cfg = provider.models.get(name)
328
+ return cfg.get("id", name) if isinstance(cfg, dict) else name
329
+
330
+
331
+ def _select_model(ui, provider_key: str, session):
332
+ """Pick a model for the just-connected provider (OpenCode-style).
333
+
334
+ Shows a menu of the provider's models with its default pre-highlighted,
335
+ plus an escape hatch for a model id not in the registry. Providers
336
+ without a static model list (Ollama, OpenRouter, custom) fall back to a
337
+ free-text id prompt. Esc keeps the current model. Returns the new
338
+ ``model_ref`` when changed, else ``None``.
339
+ """
340
+ if session is None:
341
+ return None
342
+ from aru.providers import get_provider
343
+
344
+ provider = get_provider(provider_key)
345
+ if provider is None:
346
+ return None
347
+
348
+ model_names = _list_provider_models(provider)
349
+ chosen: str | None = None
350
+
351
+ if model_names:
352
+ # Pre-highlight whichever name resolves to the provider's default id.
353
+ default_idx = 0
354
+ if provider.default_model:
355
+ default_id = _model_id_for(provider, provider.default_model)
356
+ for i, n in enumerate(model_names):
357
+ if _model_id_for(provider, n) == default_id:
358
+ default_idx = i
359
+ break
360
+ labels = [*model_names, _CUSTOM_MODEL_LABEL]
361
+ idx = ui.ask_choice(
362
+ labels,
363
+ title=f"Select a model for {provider.name}",
364
+ default=default_idx,
365
+ cancel_value=None,
366
+ )
367
+ if idx is None:
368
+ return None # keep current model
369
+ if idx == len(model_names):
370
+ chosen = ui.ask_text(
371
+ "Model id:", default=provider.default_model or ""
372
+ ).strip()
373
+ else:
374
+ chosen = model_names[idx]
375
+ else:
376
+ chosen = ui.ask_text(
377
+ f"Model id for {provider.name}:",
378
+ default=provider.default_model or "",
379
+ ).strip()
380
+
381
+ if not chosen:
382
+ return None
383
+ new_ref = f"{provider_key}/{chosen}"
384
+ session.model_ref = new_ref
385
+ ui.print(f"Model set to {new_ref}.")
386
+ return new_ref
387
+
388
+
389
+ def _connect_list(ui) -> None:
390
+ """Show stored credentials and any active provider env vars."""
391
+ import os as _os
392
+
393
+ from aru import auth
394
+ from aru.providers import get_provider, list_providers
395
+
396
+ lines: list[str] = []
397
+ stored = auth.load_auth()
398
+ lines.append(f"Stored credentials ({auth.auth_path()}):")
399
+ if stored:
400
+ for key, info in sorted(stored.items()):
401
+ provider = get_provider(key)
402
+ name = provider.name if provider else key
403
+ itype = info.get("type", "api")
404
+ if itype == "local":
405
+ detail = info.get("base_url", "")
406
+ else:
407
+ detail = _mask_key(info.get("key", ""))
408
+ lines.append(f" • {name} ({key}) [{itype}] {detail}")
409
+ else:
410
+ lines.append(" (none — run /connect to add one)")
411
+
412
+ # Active env vars that back a provider (these still work as a fallback).
413
+ active: list[str] = []
414
+ for key, provider in list_providers().items():
415
+ env = provider.api_key_env
416
+ if env and _os.environ.get(env):
417
+ active.append(f" • {provider.name} ({key}) {env}")
418
+ if active:
419
+ lines.append("")
420
+ lines.append("Active environment variables:")
421
+ lines.extend(active)
422
+
423
+ ui.print("\n".join(lines))
424
+
425
+
426
+ def _connect_logout(ui, arg: str) -> None:
427
+ """Remove a stored credential (interactive picker when no arg given)."""
428
+ from aru import auth
429
+ from aru.providers import forget_credential, get_provider
430
+
431
+ stored = auth.load_auth()
432
+ if not stored:
433
+ ui.notify("No stored credentials to remove.", "warn")
434
+ return
435
+
436
+ keys = sorted(stored.keys())
437
+ if arg:
438
+ target = arg.lower()
439
+ if target not in stored:
440
+ ui.notify(f"No stored credential for '{arg}'.", "warn")
441
+ return
442
+ else:
443
+ labels = []
444
+ for k in keys:
445
+ provider = get_provider(k)
446
+ name = provider.name if provider else k
447
+ labels.append(f"{name} ({k}) [{stored[k].get('type', 'api')}]")
448
+ idx = ui.ask_choice(
449
+ labels, title="Remove which credential?", cancel_value=None
450
+ )
451
+ if idx is None:
452
+ ui.notify("Logout cancelled.", "warn")
453
+ return
454
+ target = keys[idx]
455
+
456
+ auth.remove_credential(target)
457
+ forget_credential(target)
458
+ provider = get_provider(target)
459
+ name = provider.name if provider else target
460
+ note = ""
461
+ if provider and provider.api_key_env:
462
+ note = f" Falls back to ${provider.api_key_env} if set."
463
+ ui.print(f"Disconnected {name}.{note}")
464
+
465
+
82
466
  def handle_subagents_command(session) -> None:
83
467
  """Render the session's sub-agent trace tree (`/subagents`).
84
468
 
@@ -685,6 +1069,7 @@ def _show_help(config) -> None:
685
1069
  table.add_column("Description", style="dim")
686
1070
 
687
1071
  table.add_row("/plan <task>", "Create detailed implementation plan")
1072
+ table.add_row("/connect [provider]", "Connect a provider — store API key (list/logout)")
688
1073
  table.add_row("/model [provider/model]", "Switch models (e.g., ollama/llama3.1, openai/gpt-4o)")
689
1074
  table.add_row("/sessions", "List recent sessions")
690
1075
  table.add_row("/commands", "List custom commands")
@@ -41,6 +41,11 @@ class ProviderConfig:
41
41
  models: dict[str, dict[str, Any]] = field(default_factory=dict)
42
42
  options: dict[str, Any] = field(default_factory=dict)
43
43
  reasoning_effort: str | None = None # provider-level default effort
44
+ # Resolved API key injected by `/connect` (via apply_stored_credentials).
45
+ # Takes precedence over `api_key_env` so a credential the user stored
46
+ # interactively wins over a stale shell env var. Left None for providers
47
+ # configured only through env vars (the legacy path).
48
+ api_key: str | None = None
44
49
 
45
50
 
46
51
  # Built-in providers with sensible defaults
@@ -857,12 +862,90 @@ def _create_provider_model(
857
862
 
858
863
 
859
864
  def _resolve_api_key(provider: ProviderConfig) -> str | None:
860
- """Resolve API key from environment variable."""
865
+ """Resolve the API key for a provider.
866
+
867
+ Priority: a key stored via ``/connect`` (``provider.api_key``, populated
868
+ by :func:`apply_stored_credentials`) wins over the provider's
869
+ ``api_key_env`` environment variable. Existing env-only setups are
870
+ unaffected — ``api_key`` stays ``None`` until the user connects.
871
+ """
872
+ if provider.api_key:
873
+ return provider.api_key
861
874
  if provider.api_key_env:
862
875
  return os.environ.get(provider.api_key_env)
863
876
  return None
864
877
 
865
878
 
879
+ def apply_stored_credentials() -> None:
880
+ """Layer credentials from ``~/.aru/auth.json`` onto the provider registry.
881
+
882
+ Called at startup (after config load) and again right after ``/connect``
883
+ so the in-memory registry reflects stored keys without a restart. For a
884
+ built-in provider this sets ``api_key`` (and any ``base_url`` override);
885
+ for an unknown provider id it registers a fresh OpenAI-compatible
886
+ ``ProviderConfig`` so custom endpoints connected via ``/connect`` work
887
+ with no ``aru.json`` editing. Best-effort: a missing/garbled file is a
888
+ no-op.
889
+ """
890
+ try:
891
+ from aru import auth
892
+ data = auth.load_auth()
893
+ except Exception as exc: # pragma: no cover — never fail startup over auth
894
+ logger.warning("apply_stored_credentials: %s", exc)
895
+ return
896
+ if not data:
897
+ return
898
+
899
+ from aru.context import MODEL_CONTEXT_LIMITS
900
+
901
+ for key, info in data.items():
902
+ if not isinstance(info, dict):
903
+ continue
904
+ api_key = info.get("key")
905
+ base_url = info.get("base_url")
906
+ default_model = info.get("default_model") or None
907
+
908
+ existing = _providers.get(key)
909
+ if existing is not None:
910
+ if api_key:
911
+ existing.api_key = api_key
912
+ # Populate the env var too (without clobbering an explicit
913
+ # one) so any code path that reads it directly still works.
914
+ if existing.api_key_env:
915
+ os.environ.setdefault(existing.api_key_env, api_key)
916
+ if base_url:
917
+ existing.base_url = base_url
918
+ if default_model:
919
+ existing.default_model = default_model
920
+ else:
921
+ provider_type = info.get("provider_type", "openai")
922
+ _providers[key] = ProviderConfig(
923
+ name=info.get("name", key),
924
+ api_key_env=info.get("api_key_env"),
925
+ base_url=base_url,
926
+ default_model=default_model,
927
+ models={},
928
+ options={"_provider_type": provider_type},
929
+ api_key=api_key,
930
+ )
931
+
932
+ # Honour an explicit context window for the default model.
933
+ cl = info.get("context_limit")
934
+ if isinstance(cl, int) and cl > 0 and default_model:
935
+ MODEL_CONTEXT_LIMITS.setdefault(default_model, cl)
936
+
937
+
938
+ def forget_credential(provider_key: str) -> None:
939
+ """Clear an in-memory stored key so the provider falls back to its env var.
940
+
941
+ Paired with ``auth.remove_credential`` on ``/connect logout`` — the file
942
+ write removes persistence, this drops the live override.
943
+ """
944
+ provider = _providers.get(provider_key)
945
+ if provider is not None:
946
+ provider.api_key = None
947
+
948
+
866
949
  # ---------------------------------------------------------------------------
867
950
  # Convenience: list available models for display
868
951
  # ---------------------------------------------------------------------------
@@ -24,6 +24,7 @@ specialized read-only tool set and instructions.
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
+ import asyncio
27
28
  import sys
28
29
 
29
30
  from rich.panel import Panel
@@ -197,7 +198,18 @@ async def exit_plan_mode(plan: str) -> str:
197
198
  session.set_plan(task=task_label, plan_content=plan_text)
198
199
  n_steps = len(session.plan_steps)
199
200
 
200
- approved, feedback = _prompt_plan_approval(session.plan_steps, n_steps)
201
+ # ``exit_plan_mode`` is an async tool, so ``agent_factory`` awaits it
202
+ # directly on the event-loop thread (no ``_thread_tool`` worker hop like
203
+ # sync tools get). ``_prompt_plan_approval`` is synchronous and in TUI
204
+ # mode invokes ``TuiUI.ask_choice`` → ``App.call_from_thread``, which
205
+ # raises "must run in a different thread from the app" when called from
206
+ # the loop thread it targets. Hop to a worker thread first so the modal
207
+ # dispatch path matches the sync-tool and runner-auto-exit paths
208
+ # (``runner.py`` does the same). ``asyncio.to_thread`` copies the
209
+ # contextvars snapshot, so ``get_ctx()`` inside still resolves.
210
+ approved, feedback = await asyncio.to_thread(
211
+ _prompt_plan_approval, session.plan_steps, n_steps
212
+ )
201
213
 
202
214
  # The approval prompt already rendered the plan panel inline, so suppress
203
215
  # the runner's coalesced end-of-batch render to avoid a duplicate.