aru-code 0.53.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.
Files changed (213) hide show
  1. {aru_code-0.53.0/aru_code.egg-info → aru_code-0.55.0}/PKG-INFO +1 -1
  2. aru_code-0.55.0/aru/__init__.py +1 -0
  3. {aru_code-0.53.0 → aru_code-0.55.0}/aru/cache_patch.py +233 -0
  4. {aru_code-0.53.0 → aru_code-0.55.0}/aru/cli.py +9 -0
  5. {aru_code-0.53.0 → aru_code-0.55.0}/aru/commands.py +1 -0
  6. {aru_code-0.53.0 → aru_code-0.55.0}/aru/permissions.py +27 -3
  7. {aru_code-0.53.0 → aru_code-0.55.0}/aru/runner.py +14 -1
  8. {aru_code-0.53.0 → aru_code-0.55.0}/aru/runtime.py +95 -0
  9. {aru_code-0.53.0 → aru_code-0.55.0}/aru/session.py +117 -8
  10. aru_code-0.55.0/aru/tools/_shared.py +145 -0
  11. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/apply_patch.py +83 -0
  12. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/lsp.py +29 -0
  13. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/registry.py +10 -1
  14. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/app.py +15 -1
  15. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/ui.py +65 -23
  16. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/completer.py +1 -0
  17. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/thinking.py +31 -4
  18. {aru_code-0.53.0 → aru_code-0.55.0/aru_code.egg-info}/PKG-INFO +1 -1
  19. {aru_code-0.53.0 → aru_code-0.55.0}/aru_code.egg-info/SOURCES.txt +1 -0
  20. {aru_code-0.53.0 → aru_code-0.55.0}/pyproject.toml +1 -1
  21. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_apply_patch.py +133 -0
  22. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cache_patch_metrics.py +4 -1
  23. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli.py +5 -1
  24. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_context.py +15 -6
  25. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_lsp_rename.py +54 -0
  26. aru_code-0.55.0/tests/test_permission_timeout_suspension.py +171 -0
  27. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_permission_flow.py +59 -0
  28. aru_code-0.53.0/aru/__init__.py +0 -1
  29. aru_code-0.53.0/aru/tools/_shared.py +0 -94
  30. {aru_code-0.53.0 → aru_code-0.55.0}/LICENSE +0 -0
  31. {aru_code-0.53.0 → aru_code-0.55.0}/README.md +0 -0
  32. {aru_code-0.53.0 → aru_code-0.55.0}/aru/_debug/__init__.py +0 -0
  33. {aru_code-0.53.0 → aru_code-0.55.0}/aru/_debug/analyze_trace.py +0 -0
  34. {aru_code-0.53.0 → aru_code-0.55.0}/aru/_debug/loop_tracer.py +0 -0
  35. {aru_code-0.53.0 → aru_code-0.55.0}/aru/agent_factory.py +0 -0
  36. {aru_code-0.53.0 → aru_code-0.55.0}/aru/agents/__init__.py +0 -0
  37. {aru_code-0.53.0 → aru_code-0.55.0}/aru/agents/base.py +0 -0
  38. {aru_code-0.53.0 → aru_code-0.55.0}/aru/agents/catalog.py +0 -0
  39. {aru_code-0.53.0 → aru_code-0.55.0}/aru/agents/planner.py +0 -0
  40. {aru_code-0.53.0 → aru_code-0.55.0}/aru/checkpoints.py +0 -0
  41. {aru_code-0.53.0 → aru_code-0.55.0}/aru/completers.py +0 -0
  42. {aru_code-0.53.0 → aru_code-0.55.0}/aru/config.py +0 -0
  43. {aru_code-0.53.0 → aru_code-0.55.0}/aru/context.py +0 -0
  44. {aru_code-0.53.0 → aru_code-0.55.0}/aru/display.py +0 -0
  45. {aru_code-0.53.0 → aru_code-0.55.0}/aru/doom_loop.py +0 -0
  46. {aru_code-0.53.0 → aru_code-0.55.0}/aru/events.py +0 -0
  47. {aru_code-0.53.0 → aru_code-0.55.0}/aru/format/__init__.py +0 -0
  48. {aru_code-0.53.0 → aru_code-0.55.0}/aru/format/manager.py +0 -0
  49. {aru_code-0.53.0 → aru_code-0.55.0}/aru/format/runner.py +0 -0
  50. {aru_code-0.53.0 → aru_code-0.55.0}/aru/history_blocks.py +0 -0
  51. {aru_code-0.53.0 → aru_code-0.55.0}/aru/lsp/__init__.py +0 -0
  52. {aru_code-0.53.0 → aru_code-0.55.0}/aru/lsp/client.py +0 -0
  53. {aru_code-0.53.0 → aru_code-0.55.0}/aru/lsp/manager.py +0 -0
  54. {aru_code-0.53.0 → aru_code-0.55.0}/aru/lsp/protocol.py +0 -0
  55. {aru_code-0.53.0 → aru_code-0.55.0}/aru/memory/__init__.py +0 -0
  56. {aru_code-0.53.0 → aru_code-0.55.0}/aru/memory/extractor.py +0 -0
  57. {aru_code-0.53.0 → aru_code-0.55.0}/aru/memory/loader.py +0 -0
  58. {aru_code-0.53.0 → aru_code-0.55.0}/aru/memory/store.py +0 -0
  59. {aru_code-0.53.0 → aru_code-0.55.0}/aru/plugin_cache.py +0 -0
  60. {aru_code-0.53.0 → aru_code-0.55.0}/aru/plugins/__init__.py +0 -0
  61. {aru_code-0.53.0 → aru_code-0.55.0}/aru/plugins/custom_tools.py +0 -0
  62. {aru_code-0.53.0 → aru_code-0.55.0}/aru/plugins/hooks.py +0 -0
  63. {aru_code-0.53.0 → aru_code-0.55.0}/aru/plugins/manager.py +0 -0
  64. {aru_code-0.53.0 → aru_code-0.55.0}/aru/plugins/tool_api.py +0 -0
  65. {aru_code-0.53.0 → aru_code-0.55.0}/aru/providers.py +0 -0
  66. {aru_code-0.53.0 → aru_code-0.55.0}/aru/select.py +0 -0
  67. {aru_code-0.53.0 → aru_code-0.55.0}/aru/sinks.py +0 -0
  68. {aru_code-0.53.0 → aru_code-0.55.0}/aru/streaming.py +0 -0
  69. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tool_policy.py +0 -0
  70. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/__init__.py +0 -0
  71. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/_diff.py +0 -0
  72. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/apply_patch_prompt.txt +0 -0
  73. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/ast_tools.py +0 -0
  74. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/codebase.py +0 -0
  75. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/delegate.py +0 -0
  76. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/delegate_prompt.txt +0 -0
  77. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/file_ops.py +0 -0
  78. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/gitignore.py +0 -0
  79. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/mcp_client.py +0 -0
  80. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/memory_tool.py +0 -0
  81. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/plan_mode.py +0 -0
  82. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/ranker.py +0 -0
  83. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/search.py +0 -0
  84. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/shell.py +0 -0
  85. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/skill.py +0 -0
  86. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/tasklist.py +0 -0
  87. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/web.py +0 -0
  88. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/worktree.py +0 -0
  89. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/__init__.py +0 -0
  90. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/log_bridge.py +0 -0
  91. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/notifications.py +0 -0
  92. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/sanitize.py +0 -0
  93. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/__init__.py +0 -0
  94. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/choice.py +0 -0
  95. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/confirm.py +0 -0
  96. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/keymap.py +0 -0
  97. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/search.py +0 -0
  98. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/session_picker.py +0 -0
  99. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/text_input.py +0 -0
  100. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/sinks.py +0 -0
  101. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/slash_bridge.py +0 -0
  102. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/themes.py +0 -0
  103. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/__init__.py +0 -0
  104. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/chat.py +0 -0
  105. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/context_pane.py +0 -0
  106. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/file_link.py +0 -0
  107. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/header.py +0 -0
  108. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/inline_choice.py +0 -0
  109. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/loaded_pane.py +0 -0
  110. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/prompt_area.py +0 -0
  111. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/prompt_queue.py +0 -0
  112. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/status.py +0 -0
  113. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/subagent_panel.py +0 -0
  114. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/tasklist_panel.py +0 -0
  115. {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/tools.py +0 -0
  116. {aru_code-0.53.0 → aru_code-0.55.0}/aru/ui.py +0 -0
  117. {aru_code-0.53.0 → aru_code-0.55.0}/aru_code.egg-info/dependency_links.txt +0 -0
  118. {aru_code-0.53.0 → aru_code-0.55.0}/aru_code.egg-info/entry_points.txt +0 -0
  119. {aru_code-0.53.0 → aru_code-0.55.0}/aru_code.egg-info/requires.txt +0 -0
  120. {aru_code-0.53.0 → aru_code-0.55.0}/aru_code.egg-info/top_level.txt +0 -0
  121. {aru_code-0.53.0 → aru_code-0.55.0}/setup.cfg +0 -0
  122. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_agents_base.py +0 -0
  123. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_agents_md_coverage.py +0 -0
  124. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_async_tool_permission.py +0 -0
  125. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cache_patch_stop_reason.py +0 -0
  126. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_catalog.py +0 -0
  127. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_chat_scrollable.py +0 -0
  128. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_checkpoints.py +0 -0
  129. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_advanced.py +0 -0
  130. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_base.py +0 -0
  131. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_completers.py +0 -0
  132. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_new.py +0 -0
  133. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_run_cli.py +0 -0
  134. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_session.py +0 -0
  135. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_shell.py +0 -0
  136. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_codebase.py +0 -0
  137. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_confabulation_regression.py +0 -0
  138. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_config.py +0 -0
  139. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_context_pane.py +0 -0
  140. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cwd_awareness.py +0 -0
  141. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_delegate.py +0 -0
  142. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_doom_loop.py +0 -0
  143. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_events_backward_compat.py +0 -0
  144. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_events_schema.py +0 -0
  145. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_fork_ctx_concurrency.py +0 -0
  146. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_format.py +0 -0
  147. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_gitignore.py +0 -0
  148. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_guardrails_scenarios.py +0 -0
  149. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_invoke_skill.py +0 -0
  150. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_invoked_skills.py +0 -0
  151. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_loaded_pane_path.py +0 -0
  152. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_lsp.py +0 -0
  153. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_main.py +0 -0
  154. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_markdown_to_text.py +0 -0
  155. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_mcp_client.py +0 -0
  156. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_mcp_health.py +0 -0
  157. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_memory.py +0 -0
  158. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_memory_tool.py +0 -0
  159. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_microcompact.py +0 -0
  160. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_permissions.py +0 -0
  161. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_plan_mode_refactor.py +0 -0
  162. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_plugin_cache.py +0 -0
  163. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_plugin_errors.py +0 -0
  164. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_plugin_hooks_v2.py +0 -0
  165. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_plugins.py +0 -0
  166. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_providers.py +0 -0
  167. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_ranker.py +0 -0
  168. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_reasoning.py +0 -0
  169. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_runner_interrupt.py +0 -0
  170. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_runner_recovery.py +0 -0
  171. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_runtime.py +0 -0
  172. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_select.py +0 -0
  173. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_session_free_cost.py +0 -0
  174. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_skill_disallowed_tools.py +0 -0
  175. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_status_breakdown.py +0 -0
  176. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_status_cost.py +0 -0
  177. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_streaming_sink.py +0 -0
  178. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_subagent_tool_events.py +0 -0
  179. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tasklist.py +0 -0
  180. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_thread_tool_timeout.py +0 -0
  181. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tool_policy.py +0 -0
  182. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_truncation_marker.py +0 -0
  183. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_app_boot.py +0 -0
  184. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_bindings.py +0 -0
  185. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_bus_flow.py +0 -0
  186. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_chat.py +0 -0
  187. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_chat_adversarial.py +0 -0
  188. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_completer.py +0 -0
  189. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_completer_dynamic.py +0 -0
  190. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_copy.py +0 -0
  191. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_error_display.py +0 -0
  192. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_file_link.py +0 -0
  193. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_input_behaviour.py +0 -0
  194. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_layer12_recovery.py +0 -0
  195. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_layer13_recovery.py +0 -0
  196. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_mention_expand.py +0 -0
  197. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_modals.py +0 -0
  198. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_mode_cycle.py +0 -0
  199. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_native_selection.py +0 -0
  200. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_plan_task_render.py +0 -0
  201. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_prompt_queue.py +0 -0
  202. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_shell_bang.py +0 -0
  203. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_sidebar_toggle.py +0 -0
  204. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_slash_bridge.py +0 -0
  205. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_slash_model.py +0 -0
  206. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_snapshot_smoke.py +0 -0
  207. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_subagent_panel.py +0 -0
  208. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_theme.py +0 -0
  209. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_thinking_and_boot.py +0 -0
  210. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_widgets_visual.py +0 -0
  211. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_ui_adapter.py +0 -0
  212. {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_worktree.py +0 -0
  213. {aru_code-0.53.0 → aru_code-0.55.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.53.0
3
+ Version: 0.55.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.55.0"
@@ -23,6 +23,9 @@ regardless of which provider is used.
23
23
 
24
24
  from __future__ import annotations
25
25
 
26
+ import os as _os
27
+ import time as _time
28
+
26
29
  # Token-budget pruning (aligned with OpenCode's strategy):
27
30
  # - Protect recent tool results within a token budget
28
31
  # - Only prune if there's enough to free (avoid churn)
@@ -43,6 +46,22 @@ _last_call_cache_write: int = 0
43
46
  # We normalize "length" → "max_tokens" so callers can check a single value.
44
47
  _last_call_stop_reason: str | None = None
45
48
 
49
+ # Per-call observability ring buffer. Each accumulate_model_metrics fire
50
+ # appends one record; the ring caps at _CALL_HISTORY_MAX so a long-running
51
+ # session doesn't grow unbounded. Surfaced via /calls so users can see
52
+ # *which* models / model_types / call sites produced each request — the
53
+ # canonical "why are there N api_calls?" diagnosis surface.
54
+ _CALL_HISTORY_MAX = 200
55
+ _call_history: list[dict] = []
56
+
57
+ # Pending request metadata captured by the request-side patch right before
58
+ # the provider call goes out. Read by ``_patched_accumulate`` after the
59
+ # response lands and merged into the matching call_history record so /calls
60
+ # shows both the response usage AND a summary of what was sent. Single-
61
+ # slot global is OK: aru runs requests sequentially per ctx, and the patch
62
+ # captures-then-clears synchronously around each invocation.
63
+ _pending_request_meta: dict | None = None
64
+
46
65
  # Micro-compaction metrics (process-wide, reset by tests via
47
66
  # reset_microcompact_stats()). Recorded by _prune_tool_messages every time it
48
67
  # fires from the format_function_call_results patch. Surfaced in /cost so
@@ -105,6 +124,92 @@ def reset_last_stop_reason() -> None:
105
124
  _last_call_stop_reason = None
106
125
 
107
126
 
127
+ def _summarize_request(messages, tools=None) -> dict:
128
+ """Build a compact summary of an outgoing request for /calls.
129
+
130
+ We deliberately don't store full message bodies — a single tool result
131
+ can be tens of KB and a long session would balloon memory. We keep:
132
+
133
+ * count of messages and per-role tally
134
+ * total chars across messages (proxy for prompt size)
135
+ * snippet of the first message (usually system prompt) and the last
136
+ message (usually the freshest user/tool turn — what the model is
137
+ responding to)
138
+ * snippet of the most recent ``user`` message specifically
139
+ * tool count
140
+
141
+ Snippets are capped at 240 chars. Enough to identify the call without
142
+ storing PII-heavy or token-heavy bodies.
143
+ """
144
+ out = {
145
+ "n_messages": 0,
146
+ "roles": {},
147
+ "total_chars": 0,
148
+ "first_snippet": "",
149
+ "last_snippet": "",
150
+ "last_user_snippet": "",
151
+ "n_tools": 0,
152
+ }
153
+ try:
154
+ msgs = list(messages or [])
155
+ out["n_messages"] = len(msgs)
156
+ out["n_tools"] = len(tools or [])
157
+ last_user = ""
158
+ for i, m in enumerate(msgs):
159
+ role = (getattr(m, "role", None) or "?")
160
+ out["roles"][role] = out["roles"].get(role, 0) + 1
161
+ content = getattr(m, "content", None)
162
+ if content is None:
163
+ content = getattr(m, "text", "")
164
+ if not isinstance(content, str):
165
+ try:
166
+ content = str(content)
167
+ except Exception:
168
+ content = ""
169
+ out["total_chars"] += len(content)
170
+ if i == 0:
171
+ out["first_snippet"] = content[:240]
172
+ if role == "user":
173
+ last_user = content[:240]
174
+ if msgs:
175
+ last = msgs[-1]
176
+ lc = getattr(last, "content", None) or getattr(last, "text", "")
177
+ if not isinstance(lc, str):
178
+ try:
179
+ lc = str(lc)
180
+ except Exception:
181
+ lc = ""
182
+ out["last_snippet"] = lc[:240]
183
+ out["last_user_snippet"] = last_user
184
+ except Exception:
185
+ pass
186
+ return out
187
+
188
+
189
+ def _capture_request_meta(messages, tools=None) -> None:
190
+ """Stash a request summary into the pending slot for the next accumulate."""
191
+ global _pending_request_meta
192
+ _pending_request_meta = _summarize_request(messages, tools)
193
+
194
+
195
+ def get_call_history() -> list[dict]:
196
+ """Return a copy of the per-API-call ring buffer.
197
+
198
+ Each entry: ``{n, model_type, model_id, provider, input_tokens,
199
+ output_tokens, cache_read, cache_write, stop_reason, caller, ts}``.
200
+ ``input_tokens`` is the *normalized* value (cache stripped for OpenAI-
201
+ style providers). ``caller`` is the agno file:line that invoked
202
+ accumulate_model_metrics — useful for distinguishing main-model calls
203
+ from parser/output-model/memory/recovery calls.
204
+ """
205
+ return list(_call_history)
206
+
207
+
208
+ def reset_call_history() -> None:
209
+ """Clear the call ring buffer. Useful at session start or in tests."""
210
+ _call_history.clear()
211
+
212
+
108
213
  def get_microcompact_stats() -> dict:
109
214
  """Return process-wide micro-compaction metrics.
110
215
 
@@ -317,6 +422,72 @@ def _prune_tool_messages(messages):
317
422
  _PATCH_APPLIED = False
318
423
 
319
424
 
425
+ def _patch_request_capture():
426
+ """Wrap the agno methods that receive ``messages`` right before the
427
+ provider HTTP call so /calls can show what was actually sent.
428
+
429
+ We hook the four ``Model._{a,}invoke{_stream,}_with_retry`` methods
430
+ on ``agno.models.base.Model`` — these are the chokepoint each subclass
431
+ flows through (sync/async × stream/non-stream). Each wrapper takes a
432
+ cheap snapshot of ``kwargs["messages"]`` into ``_pending_request_meta``
433
+ immediately before delegating to the original. ``_patched_accumulate``
434
+ then reads-and-clears that slot when the matching response lands.
435
+
436
+ The wrappers are best-effort: any exception during snapshotting is
437
+ swallowed so we never break the actual model call. Stream wrappers
438
+ must remain async generators (``async for ... yield``) — collecting
439
+ the stream first would defeat streaming.
440
+ """
441
+ try:
442
+ from agno.models.base import Model
443
+ except ImportError:
444
+ return
445
+
446
+ _orig_invoke = Model._invoke_with_retry
447
+ _orig_ainvoke = Model._ainvoke_with_retry
448
+ _orig_invoke_stream = Model._invoke_stream_with_retry
449
+ _orig_ainvoke_stream = Model._ainvoke_stream_with_retry
450
+
451
+ def _wrap_invoke(self, **kwargs):
452
+ try:
453
+ _capture_request_meta(kwargs.get("messages"), kwargs.get("tools"))
454
+ except Exception:
455
+ pass
456
+ return _orig_invoke(self, **kwargs)
457
+
458
+ async def _wrap_ainvoke(self, **kwargs):
459
+ try:
460
+ _capture_request_meta(kwargs.get("messages"), kwargs.get("tools"))
461
+ except Exception:
462
+ pass
463
+ return await _orig_ainvoke(self, **kwargs)
464
+
465
+ def _wrap_invoke_stream(self, **kwargs):
466
+ try:
467
+ _capture_request_meta(kwargs.get("messages"), kwargs.get("tools"))
468
+ except Exception:
469
+ pass
470
+ # _invoke_stream_with_retry returns an Iterator (sync generator)
471
+ return _orig_invoke_stream(self, **kwargs)
472
+
473
+ async def _wrap_ainvoke_stream(self, **kwargs):
474
+ try:
475
+ _capture_request_meta(kwargs.get("messages"), kwargs.get("tools"))
476
+ except Exception:
477
+ pass
478
+ # _ainvoke_stream_with_retry is an async generator — we must
479
+ # re-yield rather than return it (returning an async generator
480
+ # from an async def function wraps it in a coroutine that yields
481
+ # the generator object, which the caller would not iterate).
482
+ async for chunk in _orig_ainvoke_stream(self, **kwargs):
483
+ yield chunk
484
+
485
+ Model._invoke_with_retry = _wrap_invoke
486
+ Model._ainvoke_with_retry = _wrap_ainvoke
487
+ Model._invoke_stream_with_retry = _wrap_invoke_stream
488
+ Model._ainvoke_stream_with_retry = _wrap_ainvoke_stream
489
+
490
+
320
491
  def apply_cache_patch():
321
492
  """Apply all patches to reduce Agno's token consumption.
322
493
 
@@ -334,6 +505,7 @@ def apply_cache_patch():
334
505
  _patch_per_call_metrics()
335
506
  _patch_stop_reason_capture()
336
507
  _patch_overflow_recovery()
508
+ _patch_request_capture()
337
509
  _PATCH_APPLIED = True
338
510
 
339
511
 
@@ -515,6 +687,10 @@ def _publish_live_metrics(
515
687
  session.total_output_tokens += output_tokens
516
688
  session.total_cache_read_tokens += cache_read
517
689
  session.total_cache_write_tokens += cache_write
690
+ # Count real API requests (one per accumulate call). track_tokens
691
+ # used to do this at turn-end (++1), which collapsed multi-tool
692
+ # turns — a turn with N tool calls = N+1 requests but counted as 1.
693
+ session.api_calls = (getattr(session, "api_calls", 0) or 0) + 1
518
694
  session._live_input_added = (
519
695
  getattr(session, "_live_input_added", 0) + input_tokens
520
696
  )
@@ -587,6 +763,16 @@ def _patch_per_call_metrics():
587
763
  global _last_call_input_tokens, _last_call_output_tokens
588
764
  global _last_call_cache_read, _last_call_cache_write
589
765
  usage = getattr(model_response, "response_usage", None)
766
+ # Capture the call site (agno file:line that invoked accumulate)
767
+ # cheaply — only when there's a usage object worth recording.
768
+ _caller_str = ""
769
+ if usage is not None:
770
+ try:
771
+ import sys as _sys
772
+ _frame = _sys._getframe(1)
773
+ _caller_str = f"{_os.path.basename(_frame.f_code.co_filename)}:{_frame.f_lineno}"
774
+ except Exception:
775
+ _caller_str = "?"
590
776
  if usage is not None:
591
777
  input_tokens = getattr(usage, "input_tokens", 0) or 0
592
778
  output_tokens = getattr(usage, "output_tokens", 0) or 0
@@ -603,12 +789,59 @@ def _patch_per_call_metrics():
603
789
  is_anthropic = "anthropic" in (provider_name or "").lower()
604
790
  if not is_anthropic and cache_read and input_tokens >= cache_read:
605
791
  input_tokens -= cache_read
792
+ # Mutate the shared usage object so the downstream
793
+ # ``_original_accumulate`` writes the *normalized* value
794
+ # into Agno's RunMetrics. Without this, RunMetrics keeps
795
+ # the raw (cache-inclusive) input while ``_last_call_*``
796
+ # and the live publish hold the normalized one, and
797
+ # ``Session.track_tokens`` reconciliation re-adds the
798
+ # cached portion as a fake "missing delta" — exactly the
799
+ # cumulative-vs-last asymmetry users see in /cost.
800
+ try:
801
+ usage.input_tokens = input_tokens
802
+ except (AttributeError, TypeError):
803
+ pass
606
804
 
607
805
  _last_call_input_tokens = input_tokens
608
806
  _last_call_output_tokens = output_tokens
609
807
  _last_call_cache_read = cache_read
610
808
  _last_call_cache_write = cache_write
611
809
 
810
+ # Per-call observability: append to the ring buffer so /calls
811
+ # can show breakdown by model_type (MODEL vs PARSER_MODEL vs
812
+ # MEMORY_MODEL etc.) and call site. Bounded to _CALL_HISTORY_MAX
813
+ # so a long session doesn't grow unbounded.
814
+ _model_id = ""
815
+ try:
816
+ _model_id = getattr(model, "id", "") or ""
817
+ except Exception:
818
+ pass
819
+ _mt_str = (
820
+ model_type.value
821
+ if hasattr(model_type, "value")
822
+ else str(model_type)
823
+ )
824
+ global _pending_request_meta
825
+ _req_meta = _pending_request_meta or {}
826
+ _pending_request_meta = None
827
+ _call_history.append({
828
+ "n": len(_call_history) + 1,
829
+ "model_type": _mt_str,
830
+ "model_id": _model_id,
831
+ "provider": provider_name or "",
832
+ "input_tokens": input_tokens,
833
+ "output_tokens": output_tokens,
834
+ "cache_read": cache_read,
835
+ "cache_write": cache_write,
836
+ "stop_reason": _last_call_stop_reason,
837
+ "caller": _caller_str,
838
+ "ts": _time.time(),
839
+ "request": _req_meta,
840
+ })
841
+ if len(_call_history) > _CALL_HISTORY_MAX:
842
+ # Keep the most recent N — drop from the front.
843
+ del _call_history[: len(_call_history) - _CALL_HISTORY_MAX]
844
+
612
845
  # Intra-turn live session update + bus publish. Gated to the
613
846
  # primary agent (subagent_depth == 0) so subagent API calls
614
847
  # don't double-count — delegate_task adds subagent totals in
@@ -761,6 +761,15 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
761
761
  ))
762
762
  continue
763
763
 
764
+ if user_input.lower() == "/calls":
765
+ console.print(Panel(
766
+ session.calls_summary,
767
+ title="[bold]Per-API-Call Breakdown[/bold]",
768
+ border_style="cyan",
769
+ padding=(1, 2),
770
+ ))
771
+ continue
772
+
764
773
  if user_input.lower() == "/subagents":
765
774
  from aru.commands import handle_subagents_command
766
775
  handle_subagents_command(session)
@@ -31,6 +31,7 @@ SLASH_COMMANDS = [
31
31
  ("/debug", "Debug utilities (plugin-errors)", "/debug <subcommand>"),
32
32
  ("/undo", "Undo last turn — restore files and/or conversation", "/undo"),
33
33
  ("/cost", "Show detailed token usage and cost", "/cost"),
34
+ ("/calls", "Show per-API-call breakdown (model_type, tokens, stop_reason, caller)", "/calls"),
34
35
  ("/yolo", "Toggle DANGEROUSLY skip all permissions (YOLO mode)", "/yolo"),
35
36
  ("/quit", "Exit aru", "/quit"),
36
37
  ]
@@ -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
- with ctx.permission_lock:
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):
@@ -9,6 +9,7 @@ from dataclasses import dataclass, field
9
9
  from rich.markdown import Markdown
10
10
 
11
11
  from aru.display import console
12
+ from aru.session import Session
12
13
 
13
14
 
14
15
  # Categories of tools that modify files (for highlighting in history)
@@ -640,18 +641,30 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
640
641
  })
641
642
 
642
643
  # Tier 2 #4: auto-memory extraction (opt-in, fire-and-forget).
644
+ # ``turn_tokens`` here is the size of the *exchange* (user message +
645
+ # assistant reply) — NOT the API call's prompt size. Earlier this
646
+ # used ``last_input_tokens + last_output_tokens``, but
647
+ # ``last_input_tokens`` includes the entire system prompt (~8K on
648
+ # aru with 30+ tools), so ``min_turn_tokens=500`` always tripped
649
+ # even on "Olá"/"ok"-style turns and the extractor fired every
650
+ # turn — burning the curator budget on nothing. Estimating from
651
+ # user+assistant char length matches the docstring intent
652
+ # ("trivial turns 'ok'/'thanks' don't trigger").
643
653
  try:
644
654
  from aru.memory.extractor import schedule_extraction_task
645
655
  from aru.runtime import get_ctx as _get_ctx
646
656
  _cfg = getattr(_get_ctx(), "config", None)
647
657
  _cfg_memory = getattr(_cfg, "memory", None) or {}
648
658
  _project_root = getattr(session, "project_root", None) or os.getcwd()
659
+ _exchange_tokens = Session.estimate_tokens(
660
+ (run_message or "") + (final_content or "")
661
+ )
649
662
  schedule_extraction_task(
650
663
  project_root=_project_root,
651
664
  user_msg=run_message or "",
652
665
  assistant_msg=final_content or "",
653
666
  config_memory=_cfg_memory,
654
- turn_tokens=_turn_tokens_in + _turn_tokens_out,
667
+ turn_tokens=_exchange_tokens,
655
668
  )
656
669
  except Exception:
657
670
  pass # extractor guards internally; swallow any unexpected raise
@@ -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