aru-code 0.36.0__tar.gz → 0.38.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 (183) hide show
  1. {aru_code-0.36.0/aru_code.egg-info → aru_code-0.38.0}/PKG-INFO +1 -1
  2. aru_code-0.38.0/aru/__init__.py +1 -0
  3. {aru_code-0.36.0 → aru_code-0.38.0}/aru/config.py +14 -1
  4. {aru_code-0.36.0 → aru_code-0.38.0}/aru/memory/__init__.py +9 -1
  5. {aru_code-0.36.0 → aru_code-0.38.0}/aru/memory/store.py +40 -9
  6. {aru_code-0.36.0 → aru_code-0.38.0}/aru/runner.py +20 -0
  7. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/memory_tool.py +63 -5
  8. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/registry.py +2 -2
  9. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/widgets/chat.py +217 -19
  10. {aru_code-0.36.0 → aru_code-0.38.0/aru_code.egg-info}/PKG-INFO +1 -1
  11. {aru_code-0.36.0 → aru_code-0.38.0}/aru_code.egg-info/SOURCES.txt +1 -0
  12. {aru_code-0.36.0 → aru_code-0.38.0}/pyproject.toml +1 -1
  13. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_memory.py +82 -1
  14. aru_code-0.38.0/tests/test_runner_interrupt.py +70 -0
  15. aru_code-0.36.0/aru/__init__.py +0 -1
  16. {aru_code-0.36.0 → aru_code-0.38.0}/LICENSE +0 -0
  17. {aru_code-0.36.0 → aru_code-0.38.0}/README.md +0 -0
  18. {aru_code-0.36.0 → aru_code-0.38.0}/aru/agent_factory.py +0 -0
  19. {aru_code-0.36.0 → aru_code-0.38.0}/aru/agents/__init__.py +0 -0
  20. {aru_code-0.36.0 → aru_code-0.38.0}/aru/agents/base.py +0 -0
  21. {aru_code-0.36.0 → aru_code-0.38.0}/aru/agents/catalog.py +0 -0
  22. {aru_code-0.36.0 → aru_code-0.38.0}/aru/agents/planner.py +0 -0
  23. {aru_code-0.36.0 → aru_code-0.38.0}/aru/cache_patch.py +0 -0
  24. {aru_code-0.36.0 → aru_code-0.38.0}/aru/checkpoints.py +0 -0
  25. {aru_code-0.36.0 → aru_code-0.38.0}/aru/cli.py +0 -0
  26. {aru_code-0.36.0 → aru_code-0.38.0}/aru/commands.py +0 -0
  27. {aru_code-0.36.0 → aru_code-0.38.0}/aru/completers.py +0 -0
  28. {aru_code-0.36.0 → aru_code-0.38.0}/aru/context.py +0 -0
  29. {aru_code-0.36.0 → aru_code-0.38.0}/aru/display.py +0 -0
  30. {aru_code-0.36.0 → aru_code-0.38.0}/aru/events.py +0 -0
  31. {aru_code-0.36.0 → aru_code-0.38.0}/aru/format/__init__.py +0 -0
  32. {aru_code-0.36.0 → aru_code-0.38.0}/aru/format/manager.py +0 -0
  33. {aru_code-0.36.0 → aru_code-0.38.0}/aru/format/runner.py +0 -0
  34. {aru_code-0.36.0 → aru_code-0.38.0}/aru/history_blocks.py +0 -0
  35. {aru_code-0.36.0 → aru_code-0.38.0}/aru/lsp/__init__.py +0 -0
  36. {aru_code-0.36.0 → aru_code-0.38.0}/aru/lsp/client.py +0 -0
  37. {aru_code-0.36.0 → aru_code-0.38.0}/aru/lsp/manager.py +0 -0
  38. {aru_code-0.36.0 → aru_code-0.38.0}/aru/lsp/protocol.py +0 -0
  39. {aru_code-0.36.0 → aru_code-0.38.0}/aru/memory/extractor.py +0 -0
  40. {aru_code-0.36.0 → aru_code-0.38.0}/aru/memory/loader.py +0 -0
  41. {aru_code-0.36.0 → aru_code-0.38.0}/aru/permissions.py +0 -0
  42. {aru_code-0.36.0 → aru_code-0.38.0}/aru/plugin_cache.py +0 -0
  43. {aru_code-0.36.0 → aru_code-0.38.0}/aru/plugins/__init__.py +0 -0
  44. {aru_code-0.36.0 → aru_code-0.38.0}/aru/plugins/custom_tools.py +0 -0
  45. {aru_code-0.36.0 → aru_code-0.38.0}/aru/plugins/hooks.py +0 -0
  46. {aru_code-0.36.0 → aru_code-0.38.0}/aru/plugins/manager.py +0 -0
  47. {aru_code-0.36.0 → aru_code-0.38.0}/aru/plugins/tool_api.py +0 -0
  48. {aru_code-0.36.0 → aru_code-0.38.0}/aru/providers.py +0 -0
  49. {aru_code-0.36.0 → aru_code-0.38.0}/aru/runtime.py +0 -0
  50. {aru_code-0.36.0 → aru_code-0.38.0}/aru/select.py +0 -0
  51. {aru_code-0.36.0 → aru_code-0.38.0}/aru/session.py +0 -0
  52. {aru_code-0.36.0 → aru_code-0.38.0}/aru/sinks.py +0 -0
  53. {aru_code-0.36.0 → aru_code-0.38.0}/aru/streaming.py +0 -0
  54. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tool_policy.py +0 -0
  55. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/__init__.py +0 -0
  56. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/_diff.py +0 -0
  57. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/_shared.py +0 -0
  58. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/apply_patch.py +0 -0
  59. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/apply_patch_prompt.txt +0 -0
  60. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/ast_tools.py +0 -0
  61. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/codebase.py +0 -0
  62. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/delegate.py +0 -0
  63. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/delegate_prompt.txt +0 -0
  64. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/file_ops.py +0 -0
  65. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/gitignore.py +0 -0
  66. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/lsp.py +0 -0
  67. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/mcp_client.py +0 -0
  68. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/plan_mode.py +0 -0
  69. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/ranker.py +0 -0
  70. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/search.py +0 -0
  71. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/shell.py +0 -0
  72. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/skill.py +0 -0
  73. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/tasklist.py +0 -0
  74. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/web.py +0 -0
  75. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tools/worktree.py +0 -0
  76. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/__init__.py +0 -0
  77. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/app.py +0 -0
  78. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/screens/__init__.py +0 -0
  79. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/screens/choice.py +0 -0
  80. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/screens/confirm.py +0 -0
  81. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/screens/search.py +0 -0
  82. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/screens/text_input.py +0 -0
  83. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/sinks.py +0 -0
  84. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/slash_bridge.py +0 -0
  85. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/ui.py +0 -0
  86. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/widgets/__init__.py +0 -0
  87. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/widgets/completer.py +0 -0
  88. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/widgets/context_pane.py +0 -0
  89. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/widgets/header.py +0 -0
  90. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/widgets/inline_choice.py +0 -0
  91. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/widgets/loaded_pane.py +0 -0
  92. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/widgets/status.py +0 -0
  93. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/widgets/thinking.py +0 -0
  94. {aru_code-0.36.0 → aru_code-0.38.0}/aru/tui/widgets/tools.py +0 -0
  95. {aru_code-0.36.0 → aru_code-0.38.0}/aru/ui.py +0 -0
  96. {aru_code-0.36.0 → aru_code-0.38.0}/aru_code.egg-info/dependency_links.txt +0 -0
  97. {aru_code-0.36.0 → aru_code-0.38.0}/aru_code.egg-info/entry_points.txt +0 -0
  98. {aru_code-0.36.0 → aru_code-0.38.0}/aru_code.egg-info/requires.txt +0 -0
  99. {aru_code-0.36.0 → aru_code-0.38.0}/aru_code.egg-info/top_level.txt +0 -0
  100. {aru_code-0.36.0 → aru_code-0.38.0}/setup.cfg +0 -0
  101. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_agents_base.py +0 -0
  102. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_agents_md_coverage.py +0 -0
  103. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_apply_patch.py +0 -0
  104. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_async_tool_permission.py +0 -0
  105. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_cache_patch_metrics.py +0 -0
  106. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_cache_patch_stop_reason.py +0 -0
  107. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_catalog.py +0 -0
  108. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_chat_scrollable.py +0 -0
  109. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_checkpoints.py +0 -0
  110. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_cli.py +0 -0
  111. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_cli_advanced.py +0 -0
  112. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_cli_base.py +0 -0
  113. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_cli_completers.py +0 -0
  114. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_cli_new.py +0 -0
  115. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_cli_run_cli.py +0 -0
  116. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_cli_session.py +0 -0
  117. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_cli_shell.py +0 -0
  118. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_codebase.py +0 -0
  119. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_confabulation_regression.py +0 -0
  120. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_config.py +0 -0
  121. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_context.py +0 -0
  122. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_context_pane.py +0 -0
  123. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_cwd_awareness.py +0 -0
  124. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_delegate.py +0 -0
  125. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_events_backward_compat.py +0 -0
  126. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_events_schema.py +0 -0
  127. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_fork_ctx_concurrency.py +0 -0
  128. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_format.py +0 -0
  129. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_gitignore.py +0 -0
  130. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_guardrails_scenarios.py +0 -0
  131. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_invoke_skill.py +0 -0
  132. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_invoked_skills.py +0 -0
  133. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_loaded_pane_path.py +0 -0
  134. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_lsp.py +0 -0
  135. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_lsp_rename.py +0 -0
  136. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_main.py +0 -0
  137. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_markdown_to_text.py +0 -0
  138. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_mcp_client.py +0 -0
  139. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_mcp_health.py +0 -0
  140. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_memory_tool.py +0 -0
  141. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_microcompact.py +0 -0
  142. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_permissions.py +0 -0
  143. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_plan_mode_refactor.py +0 -0
  144. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_plugin_cache.py +0 -0
  145. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_plugin_errors.py +0 -0
  146. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_plugin_hooks_v2.py +0 -0
  147. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_plugins.py +0 -0
  148. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_providers.py +0 -0
  149. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_ranker.py +0 -0
  150. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_reasoning.py +0 -0
  151. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_runner_recovery.py +0 -0
  152. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_runtime.py +0 -0
  153. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_select.py +0 -0
  154. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_skill_disallowed_tools.py +0 -0
  155. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_status_breakdown.py +0 -0
  156. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_status_cost.py +0 -0
  157. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_streaming_sink.py +0 -0
  158. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tasklist.py +0 -0
  159. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_thread_tool_timeout.py +0 -0
  160. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tool_policy.py +0 -0
  161. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_truncation_marker.py +0 -0
  162. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_app_boot.py +0 -0
  163. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_bindings.py +0 -0
  164. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_bus_flow.py +0 -0
  165. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_chat.py +0 -0
  166. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_completer.py +0 -0
  167. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_completer_dynamic.py +0 -0
  168. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_copy.py +0 -0
  169. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_input_behaviour.py +0 -0
  170. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_mention_expand.py +0 -0
  171. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_modals.py +0 -0
  172. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_mode_cycle.py +0 -0
  173. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_native_selection.py +0 -0
  174. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_permission_flow.py +0 -0
  175. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_plan_task_render.py +0 -0
  176. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_sidebar_toggle.py +0 -0
  177. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_slash_bridge.py +0 -0
  178. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_snapshot_smoke.py +0 -0
  179. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_thinking_and_boot.py +0 -0
  180. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_tui_widgets_visual.py +0 -0
  181. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_ui_adapter.py +0 -0
  182. {aru_code-0.36.0 → aru_code-0.38.0}/tests/test_worktree.py +0 -0
  183. {aru_code-0.36.0 → aru_code-0.38.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.36.0
3
+ Version: 0.38.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.38.0"
@@ -227,7 +227,20 @@ class AgentConfig:
227
227
  try:
228
228
  from aru.memory.loader import memory_section_for_prompt
229
229
  import os
230
- section = memory_section_for_prompt(os.getcwd())
230
+ # Prefer session.project_root (stable across subdir/worktree
231
+ # invocations) so memory is keyed to the project, not to
232
+ # whichever cwd `aru` happened to be launched from. Fall
233
+ # back to os.getcwd() during bootstrap or outside a ctx.
234
+ project_root = os.getcwd()
235
+ try:
236
+ from aru.runtime import get_ctx
237
+ _session = getattr(get_ctx(), "session", None)
238
+ _pr = getattr(_session, "project_root", None) if _session else None
239
+ if _pr:
240
+ project_root = _pr
241
+ except LookupError:
242
+ pass
243
+ section = memory_section_for_prompt(project_root)
231
244
  if section:
232
245
  parts.append(section.strip())
233
246
  except Exception: # pragma: no cover — memory module failure mustn't break prompts
@@ -10,11 +10,19 @@ Components:
10
10
 
11
11
  Storage layout:
12
12
 
13
- ~/.aru/projects/<sha256(project_root)[:12]>/memory/
13
+ ~/.aru/projects/<path-encoded>/memory/
14
14
  ├── MEMORY.md # one-line-per-memory index
15
15
  ├── feedback_*.md # one file per memory, YAML frontmatter + body
16
16
  └── user_*.md
17
17
 
18
+ ``<path-encoded>`` mirrors Claude Code's scheme: every non-alphanumeric
19
+ character in ``abspath(project_root)`` becomes a dash. Example::
20
+
21
+ D:\\OneDrive\\python_projects\\aru -> D--OneDrive-python-projects-aru
22
+
23
+ The directory is created lazily on the first ``write_memory`` call, so a
24
+ project that never writes a memory never leaves an empty folder behind.
25
+
18
26
  Config (aru.json):
19
27
 
20
28
  {
@@ -11,7 +11,6 @@ etc. until free.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- import hashlib
15
14
  import os
16
15
  import re
17
16
  from dataclasses import dataclass
@@ -44,18 +43,48 @@ class MemoryEntry:
44
43
 
45
44
  # ── Paths ────────────────────────────────────────────────────────────
46
45
 
47
- def _project_hash(project_root: str) -> str:
48
- return hashlib.sha256(os.path.abspath(project_root).encode("utf-8")).hexdigest()[:12]
46
+ _PATH_ENCODE_RE = re.compile(r"[^A-Za-z0-9]")
49
47
 
50
48
 
51
- def memory_dir_for_project(project_root: str, base: str | None = None) -> Path:
52
- """Return (and create, if needed) the memory directory for *project_root*.
49
+ def _encode_project_path(project_root: str) -> str:
50
+ """Encode an absolute project path as a human-readable folder name.
53
51
 
54
- Defaults to ``~/.aru/projects/<hash>/memory``. Override ``base`` (test-only).
52
+ Matches Claude Code's scheme: every non-alphanumeric character (drive
53
+ colon, path separator, underscore, dot, dash, space, ...) becomes a
54
+ single dash, without collapsing runs. So ``D:\\`` → ``D--`` (colon
55
+ AND backslash each become a dash).
56
+
57
+ A quick ``ls ~/.aru/projects`` then shows which folder belongs to
58
+ which project without needing to decode a hash.
59
+
60
+ Examples::
61
+
62
+ D:\\OneDrive\\python_projects\\aru -> D--OneDrive-python-projects-aru
63
+ /home/u/proj -> -home-u-proj
64
+ """
65
+ return _PATH_ENCODE_RE.sub("-", os.path.abspath(project_root))
66
+
67
+
68
+ def memory_dir_for_project(
69
+ project_root: str,
70
+ base: str | None = None,
71
+ *,
72
+ create: bool = False,
73
+ ) -> Path:
74
+ """Return the memory directory path for *project_root*.
75
+
76
+ ``~/.aru/projects/<path-encoded>/memory`` by default. Override ``base``
77
+ for tests.
78
+
79
+ The directory is **not** created on disk unless ``create=True``. Read
80
+ paths (``load_memory_index``, ``list_memories``) pass the default so
81
+ a project that never writes a memory leaves no empty folder behind.
82
+ ``write_memory`` passes ``create=True``.
55
83
  """
56
84
  base_path = Path(base) if base else Path.home() / ".aru" / "projects"
57
- d = base_path / _project_hash(project_root) / "memory"
58
- d.mkdir(parents=True, exist_ok=True)
85
+ d = base_path / _encode_project_path(project_root) / "memory"
86
+ if create:
87
+ d.mkdir(parents=True, exist_ok=True)
59
88
  return d
60
89
 
61
90
 
@@ -153,7 +182,7 @@ def write_memory(project_root: str, entry: MemoryEntry,
153
182
  f"Invalid memory type {entry.type!r}; must be one of "
154
183
  f"{sorted(VALID_MEMORY_TYPES)}."
155
184
  )
156
- mem_dir = memory_dir_for_project(project_root, base=base)
185
+ mem_dir = memory_dir_for_project(project_root, base=base, create=True)
157
186
  slug = _unique_slug(mem_dir, _slugify(entry.name, entry.type))
158
187
  entry.slug = slug
159
188
  (mem_dir / entry.filename).write_text(_render_memory_file(entry), encoding="utf-8")
@@ -239,6 +268,8 @@ def search_memories(
239
268
  def delete_memory(project_root: str, slug: str, base: str | None = None) -> bool:
240
269
  """Delete the memory file + remove its index line. True if something was removed."""
241
270
  mem_dir = memory_dir_for_project(project_root, base=base)
271
+ if not mem_dir.exists():
272
+ return False
242
273
  path = mem_dir / f"{slug}.md"
243
274
  removed = path.exists()
244
275
  if removed:
@@ -670,6 +670,26 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
670
670
  except Exception:
671
671
  pass
672
672
  console.print("\n[yellow]Interrupted.[/yellow]")
673
+ # Python 3.11+: ``asyncio.run`` cancels the main task on SIGINT.
674
+ # Catching CancelledError here is not enough — the task stays in a
675
+ # "cancelling" state, so the caller's NEXT await (typically the
676
+ # REPL prompt in cli.py) re-raises CancelledError immediately,
677
+ # which looks to the user like "Ctrl+C exits aru". ``uncancel()``
678
+ # resets that counter so the turn ends cleanly and the REPL keeps
679
+ # running.
680
+ try:
681
+ current = asyncio.current_task()
682
+ if current is not None:
683
+ current.uncancel()
684
+ except Exception:
685
+ pass
686
+ # Mirror the TUI's reset at turn start — clear the shared abort
687
+ # flag so the next turn isn't short-circuited by leftover state.
688
+ try:
689
+ from aru.runtime import reset_abort
690
+ reset_abort()
691
+ except Exception:
692
+ pass
673
693
  except Exception as e:
674
694
  try:
675
695
  sink.exit()
@@ -1,17 +1,20 @@
1
- """Agent-facing memory query tool — Tier 3 #3.
1
+ """Agent-facing memory tools — Tier 3 #3.
2
2
 
3
- Exposes the per-project memory store (written by the Tier 2 auto-extractor)
4
- as a read-only tool. Two modes:
3
+ Exposes the per-project memory store as a pair of tools:
5
4
 
6
5
  - ``memory_search(slug="...")`` → returns the full body of one memory
7
6
  - ``memory_search(query="...")`` → keyword substring search over
8
7
  name / description / body, returns a
9
8
  ranked list with 200-char previews
10
9
  - ``memory_search()`` → summary stats by type
10
+ - ``memory_write(name, body, ...)`` → persist a new memory explicitly,
11
+ without waiting for the turn.end
12
+ extractor
11
13
 
12
14
  The system prompt already receives ``MEMORY.md`` as an index at startup
13
- (Tier 2 #4). This tool is the complement for *reading* a specific body
14
- or searching when the index alone isn't enough.
15
+ (Tier 2 #4). ``memory_search`` is the read complement; ``memory_write``
16
+ lets the agent honour direct user requests like "save X to memory" that
17
+ would otherwise fall through the extractor's threshold.
15
18
  """
16
19
 
17
20
  from __future__ import annotations
@@ -20,10 +23,12 @@ import os
20
23
  from collections import Counter
21
24
 
22
25
  from aru.memory.store import (
26
+ MemoryEntry,
23
27
  VALID_MEMORY_TYPES,
24
28
  list_memories,
25
29
  read_memory,
26
30
  search_memories,
31
+ write_memory,
27
32
  )
28
33
  from aru.runtime import get_ctx
29
34
 
@@ -106,3 +111,56 @@ def memory_search(query: str = "", slug: str = "") -> str:
106
111
  f"\n body: {preview}"
107
112
  )
108
113
  return "\n".join(lines)
114
+
115
+
116
+ def memory_write(name: str, body: str, type: str = "user",
117
+ description: str = "") -> str:
118
+ """Persist a durable memory for this project across future sessions.
119
+
120
+ Use when the user explicitly asks to save / remember / "lembra" / "salva"
121
+ something that should survive session boundaries. Pick the type carefully:
122
+
123
+ - ``user`` — user's persistent preferences or workflow rules
124
+ ("prefer pytest", "always type hints")
125
+ - ``feedback`` — corrections the user gave ("don't mock the DB, got burned")
126
+ - ``project`` — project-level state / decisions / deadlines / incidents
127
+ - ``reference`` — pointers to external systems (dashboards, tickets, docs)
128
+
129
+ Do NOT save:
130
+ - Code patterns or anything derivable from reading the repo
131
+ - Ephemeral conversation state
132
+ - Duplicates of what is already in the memory index
133
+
134
+ Args:
135
+ name: Short title (under 60 chars). Used as the memory's display name
136
+ and as the base for its filename slug.
137
+ body: The fact to remember (under 400 chars). Prefer a single
138
+ declarative sentence; future sessions see this verbatim.
139
+ type: One of ``user`` / ``feedback`` / ``project`` / ``reference``.
140
+ Defaults to ``user``.
141
+ description: Optional one-line summary for the MEMORY.md index
142
+ (under 100 chars). If empty, defaults to ``name``.
143
+ """
144
+ mtype = (type or "user").strip().lower()
145
+ if mtype not in VALID_MEMORY_TYPES:
146
+ return (
147
+ f"Invalid memory type {mtype!r}. Must be one of "
148
+ f"{sorted(VALID_MEMORY_TYPES)}."
149
+ )
150
+ name_clean = (name or "").strip()[:60]
151
+ body_clean = (body or "").strip()[:400]
152
+ desc_clean = (description or "").strip()[:100] or name_clean
153
+ if not name_clean or not body_clean:
154
+ return "memory_write requires both `name` and `body`."
155
+
156
+ entry = MemoryEntry(
157
+ name=name_clean,
158
+ description=desc_clean,
159
+ type=mtype,
160
+ body=body_clean,
161
+ )
162
+ persisted = write_memory(_project_root(), entry)
163
+ return (
164
+ f"Saved memory '{persisted.slug}' ({persisted.type}): "
165
+ f"{persisted.name}"
166
+ )
@@ -35,7 +35,7 @@ from aru.tools.lsp import (
35
35
  lsp_references,
36
36
  lsp_rename,
37
37
  )
38
- from aru.tools.memory_tool import memory_search
38
+ from aru.tools.memory_tool import memory_search, memory_write
39
39
  from aru.tools.web import web_fetch, web_search
40
40
  from aru.tools.worktree import worktree_info
41
41
 
@@ -82,7 +82,7 @@ _TASK_MGMT_TOOLS = [
82
82
  # clarity; excluded from subagent / planner / explorer sets.
83
83
  _SKILL_TOOLS = [invoke_skill]
84
84
 
85
- CORE_TOOLS = _READ_ONLY_TOOLS + _WRITE_TOOLS + [bash] + _NET_TOOLS + [delegate_task]
85
+ CORE_TOOLS = _READ_ONLY_TOOLS + _WRITE_TOOLS + [bash] + _NET_TOOLS + [delegate_task, memory_write]
86
86
 
87
87
  ALL_TOOLS = _TASK_MGMT_TOOLS + _SKILL_TOOLS + CORE_TOOLS
88
88
 
@@ -10,14 +10,53 @@ Design (per plan-reviewer):
10
10
  * NO mutation of ``RichLog.lines[-1]`` — that is Textual internal state.
11
11
  * Each assistant message is its own widget with a reactive ``buffer``;
12
12
  Textual's reactive system re-renders when it changes.
13
- * ``set_interval(0.05, _flush)`` debounces rapid content deltas so we
13
+ * ``set_interval(0.1, _flush)`` debounces rapid content deltas so we
14
14
  don't re-render on every single token.
15
15
  * Tool calls show inline with a cycling indicator that flips to a check
16
16
  when the tool completes.
17
+
18
+ ----
19
+
20
+ Post-mortem — scroll freeze (2026-04-22, ``fix/tui-freezing``)
21
+ --------------------------------------------------------------
22
+ **Symptom:** vertical scroll froze for seconds mid-stream while the
23
+ agent kept producing tokens.
24
+
25
+ **Cause:** ``watch_buffer`` re-parsed the whole (growing) buffer
26
+ through Rich.Markdown on the UI thread every flush — O(N²) over the
27
+ turn. Past ~5 KB (52 ms/parse at 20 Hz) the loop had no budget left
28
+ for scroll / input / paint. ``scroll_end(animate=False)`` compounded
29
+ it by queuing behind ``call_after_refresh``.
30
+
31
+ **Fix, four layers:**
32
+
33
+ 1. ``asyncio.to_thread`` in ``_schedule_markdown_render`` moves the
34
+ parse off the UI thread. ``_markdown_to_text`` still flattens to
35
+ one ``Text`` so mouse selection + Ctrl+C keep working (Textual's
36
+ native ``Markdown`` widget was rejected: its composite blocks
37
+ break selection).
38
+ 2. Coalescing: one render task per widget; newer deltas cause the
39
+ in-flight result to be discarded and re-rendered on the freshest
40
+ snapshot — avoids N intermediate ``update`` + layout passes.
41
+ 3. 250 ms cooldown between renders: pure-Python parse still contends
42
+ with the UI thread for the GIL; cooldown cut "notable freeze"
43
+ iterations from 55 % to 15 % in the adversarial bench.
44
+ 4. ``self.anchor()`` replaces manual ``scroll_end`` on every event —
45
+ compositor auto-follows when anchored, respects manual scroll-up,
46
+ re-engages on scroll-to-bottom. ``add_user_message`` keeps an
47
+ explicit ``scroll_end(immediate=True)`` so submitted messages are
48
+ always visible.
49
+
50
+ **If the freeze comes back** (very large + code-block-dense replies
51
+ >30 KB), next lever is incremental rendering: split at the last
52
+ stable markdown boundary (blank line outside a fence), cache the
53
+ prefix ``Text``, re-render only the tail. Rich has no incremental
54
+ API but the split is tractable.
17
55
  """
18
56
 
19
57
  from __future__ import annotations
20
58
 
59
+ import asyncio
21
60
  import io
22
61
  from typing import Any
23
62
 
@@ -98,6 +137,12 @@ class ChatMessageWidget(Static):
98
137
  super().__init__("")
99
138
  self.role = role
100
139
  self.tool_state = tool_state
140
+ # Async markdown render state — see _schedule_markdown_render. Keeps
141
+ # the Rich.Markdown parse off the UI thread so the event loop can
142
+ # process scroll / keyboard / paint events even while a 20KB+
143
+ # assistant reply is streaming in.
144
+ self._md_render_task: asyncio.Task | None = None
145
+ self._pending_md_render: bool = False
101
146
  self.set_reactive(ChatMessageWidget.buffer, initial)
102
147
  if role in ("user", "assistant", "system", "tool"):
103
148
  self.add_class(role)
@@ -108,7 +153,16 @@ class ChatMessageWidget(Static):
108
153
  self.update(self._compose_renderable())
109
154
 
110
155
  def watch_buffer(self, _old: str, _new: str) -> None:
111
- # Reactive watcher — repaint the Static when the buffer changes.
156
+ # Reactive watcher — repaint when the buffer changes.
157
+ # Assistant bubbles get their markdown rendered off-thread so the
158
+ # UI stays responsive; everything else renders synchronously
159
+ # because the cost is trivial (plain Text, ~O(len)).
160
+ if self.role == "assistant":
161
+ if _new:
162
+ self._schedule_markdown_render()
163
+ else:
164
+ self.update(Text(""))
165
+ return
112
166
  self.update(self._compose_renderable())
113
167
 
114
168
  def _compose_renderable(self) -> Any:
@@ -133,6 +187,119 @@ class ChatMessageWidget(Static):
133
187
  # system
134
188
  return Text(text)
135
189
 
190
+ def _schedule_markdown_render(self) -> None:
191
+ """Queue an off-thread markdown re-render of the assistant buffer.
192
+
193
+ Why: Rich's ``Markdown`` engine parses the whole document on every
194
+ call and — with code blocks / Pygments highlighting — costs
195
+ ~100 ms for a 10 KB buffer and ~400 ms for a 40 KB buffer.
196
+ Running that on the UI thread at 20 Hz (the debounce rate) used to
197
+ starve scroll / mouse / keyboard events once a reply grew past a
198
+ few KB, making the chat pane feel frozen even though the agent
199
+ loop was still progressing.
200
+
201
+ We hand the parse off to ``asyncio.to_thread``. The render output
202
+ is byte-identical (``_markdown_to_text`` flattens to a single
203
+ ``Text`` so click-and-drag text selection still walks characters,
204
+ preserving Ctrl+C copy UX).
205
+
206
+ Coalescing rule: if a render is still in flight, we flip
207
+ ``_pending_md_render`` and let the running task loop back with
208
+ the freshest buffer when it finishes — so the executor never sees
209
+ more than one render in flight per widget even under a burst of
210
+ deltas, and the most recent buffer always wins.
211
+ """
212
+ if self._md_render_task is not None and not self._md_render_task.done():
213
+ self._pending_md_render = True
214
+ return
215
+ try:
216
+ self._md_render_task = asyncio.create_task(self._do_markdown_render())
217
+ except RuntimeError:
218
+ # No running loop (shouldn't happen inside a Textual app, but
219
+ # defensive) — fall back to a synchronous render so the widget
220
+ # still paints *something*. The event loop starvation issue
221
+ # this method exists to solve is moot without a loop anyway.
222
+ self.update(self._compose_renderable())
223
+
224
+ # Minimum interval between successive markdown renders of the same
225
+ # bubble. Even off-thread the Rich.Markdown parse competes with the
226
+ # UI thread for the GIL (pure-Python work), so two renders running
227
+ # back-to-back while deltas stream in can still starve the loop.
228
+ # 250 ms is below the human streaming-text reading rate (text flows
229
+ # faster than the eye tracks individual updates past ~3 Hz anyway)
230
+ # and cuts per-widget render CPU by 3–4× compared to running
231
+ # flat-out on every flush.
232
+ _MD_RENDER_COOLDOWN_SEC: float = 0.25
233
+
234
+ async def _do_markdown_render(self) -> None:
235
+ """Background loop: render the current buffer off-thread, apply on return.
236
+
237
+ Loops while ``_pending_md_render`` keeps flipping on — this handles
238
+ the bursty streaming case where new deltas land while we're mid-
239
+ render. We always apply the freshest snapshot we actually rendered,
240
+ and insert a cooldown before re-rendering so a fast stream doesn't
241
+ pin a worker at 100 % and starve the event loop via GIL pressure.
242
+ """
243
+ try:
244
+ while True:
245
+ snapshot = self.buffer
246
+ if not snapshot:
247
+ return
248
+ width = max(self.size.width or 100, 20)
249
+ try:
250
+ result = await asyncio.to_thread(
251
+ _markdown_to_text, snapshot, width
252
+ )
253
+ except asyncio.CancelledError:
254
+ raise
255
+ except Exception:
256
+ return
257
+ # We may have been superseded (e.g. finalize_render cancelled
258
+ # us and painted synchronously); drop the stale result.
259
+ if self._md_render_task is not asyncio.current_task():
260
+ return
261
+ # If more deltas landed while we were rendering, skip
262
+ # applying this already-stale snapshot and loop back to
263
+ # render the newer one — each ``self.update`` triggers a
264
+ # layout pass in the compositor, and painting an
265
+ # intermediate frame that will be overwritten in ~30 ms
266
+ # is pure waste at the heart of the UI thread. This is
267
+ # the coalescing step that keeps a 30-delta burst down
268
+ # to one or two real paints.
269
+ if self._pending_md_render and self.buffer != snapshot:
270
+ self._pending_md_render = False
271
+ # Cooldown: let the UI thread drain scroll / input
272
+ # events before we kick off another expensive parse.
273
+ try:
274
+ await asyncio.sleep(self._MD_RENDER_COOLDOWN_SEC)
275
+ except asyncio.CancelledError:
276
+ raise
277
+ continue
278
+ self.update(result)
279
+ break
280
+ finally:
281
+ if self._md_render_task is asyncio.current_task():
282
+ self._md_render_task = None
283
+ self._pending_md_render = False
284
+
285
+ def finalize_render(self) -> None:
286
+ """Cancel any pending async render and paint the final markdown sync.
287
+
288
+ Called by ChatPane when the stream closes. Guarantees the last
289
+ frame the user sees is the authoritative markdown of the full
290
+ buffer — no flicker between the last async render and the bubble
291
+ being considered "done".
292
+ """
293
+ if self._md_render_task is not None:
294
+ self._md_render_task.cancel()
295
+ self._md_render_task = None
296
+ self._pending_md_render = False
297
+ if self.role == "assistant" and self.buffer:
298
+ try:
299
+ self.update(self._compose_renderable())
300
+ except Exception:
301
+ pass
302
+
136
303
  def on_resize(self, event) -> None:
137
304
  """Re-render the assistant bubble so markdown wrap follows width.
138
305
 
@@ -141,10 +308,10 @@ class ChatMessageWidget(Static):
141
308
  making the terminal wider) would leave wrap decisions stale.
142
309
  """
143
310
  if self.role == "assistant" and self.buffer:
144
- try:
145
- self.update(self._compose_renderable())
146
- except Exception:
147
- pass
311
+ # Width changed → schedule a fresh render at the new width.
312
+ # Goes through the async path so the (potentially expensive)
313
+ # re-layout doesn't stall the resize animation itself.
314
+ self._schedule_markdown_render()
148
315
 
149
316
 
150
317
  def _markdown_to_text(raw: str, width: int = 100) -> Text:
@@ -205,9 +372,13 @@ class ChatPane(VerticalScroll):
205
372
  }
206
373
  """
207
374
 
208
- # Debounce window for content-delta flushing (seconds). Chosen so
209
- # 20+ tokens/s still looks smooth without burning CPU on every byte.
210
- DEBOUNCE_SEC: float = 0.05
375
+ # Debounce window for content-delta flushing (seconds). 10 Hz is
376
+ # already visually fluid for streaming text (humans read at far
377
+ # less) and it halves the GIL contention between the UI thread and
378
+ # the off-thread Rich.Markdown parse compared to a 20 Hz flush —
379
+ # which is the difference that matters once an assistant reply
380
+ # crosses a few KB and each parse pass itself takes ~100 ms.
381
+ DEBOUNCE_SEC: float = 0.1
211
382
 
212
383
  def __init__(self) -> None:
213
384
  super().__init__()
@@ -217,6 +388,17 @@ class ChatPane(VerticalScroll):
217
388
  self._tool_widgets: dict[str, ChatMessageWidget] = {}
218
389
 
219
390
  def on_mount(self) -> None:
391
+ # Engage Textual's anchor: the compositor will auto-scroll this
392
+ # container to the bottom whenever its virtual size grows, without
393
+ # us enqueuing a ``scroll_end`` after every delta / tool event.
394
+ # (a) kills the ``call_after_refresh`` backlog that piled up when
395
+ # the UI thread was busy rendering markdown; (b) releases the anchor
396
+ # when the user manually scrolls up, so they can read history
397
+ # mid-stream without the viewport snapping back every 50 ms; and
398
+ # (c) re-engages automatically when they return to the bottom via
399
+ # ``_check_anchor``. Matches Textual's own "streaming Markdown"
400
+ # recipe (see ``Markdown.get_stream`` docstring).
401
+ self.anchor()
220
402
  # Periodic flush; cheap because the reactive watcher already
221
403
  # debounces repaints when buffer doesn't actually change.
222
404
  self.set_interval(self.DEBOUNCE_SEC, self._flush_pending_delta)
@@ -227,12 +409,19 @@ class ChatPane(VerticalScroll):
227
409
  self._close_active_assistant()
228
410
  self._active_assistant = None
229
411
  self.mount(ChatMessageWidget(role="user", initial=text))
230
- self.scroll_end(animate=False)
412
+ # Force-scroll on user-initiated events even if the anchor was
413
+ # released (user scrolled up to read history, then hit Enter).
414
+ # ``immediate=True`` bypasses ``call_after_refresh`` so we don't
415
+ # queue behind a busy render pipeline. Textual's scroll_end also
416
+ # clears ``_anchor_released`` when ``_anchored`` is set.
417
+ self.scroll_end(immediate=True, animate=False)
231
418
 
232
419
  def add_system_message(self, text: str) -> None:
233
420
  self._close_active_assistant()
234
421
  self.mount(ChatMessageWidget(role="system", initial=text))
235
- self.scroll_end(animate=False)
422
+ # No forced scroll — anchor handles auto-follow when engaged; if
423
+ # the user scrolled up to read older content, a passive system
424
+ # line shouldn't yank them back to the bottom.
236
425
 
237
426
  def add_renderable(
238
427
  self,
@@ -271,14 +460,14 @@ class ChatPane(VerticalScroll):
271
460
  wrapper.mount(widget)
272
461
  else:
273
462
  self.mount(widget)
274
- self.scroll_end(animate=False)
463
+ # Anchor handles follow-on scrolling; no explicit scroll here.
275
464
 
276
465
  def start_assistant_message(self) -> None:
277
466
  """Open a new assistant message to accumulate deltas into."""
278
467
  self._close_active_assistant()
279
468
  self._active_assistant = ChatMessageWidget(role="assistant", initial="")
280
469
  self.mount(self._active_assistant)
281
- self.scroll_end(animate=False)
470
+ # Anchor handles scrolling as the bubble fills; no explicit scroll.
282
471
 
283
472
  def append_assistant_delta(self, delta: str) -> None:
284
473
  """Accumulate content into the active assistant message (debounced)."""
@@ -289,17 +478,22 @@ class ChatPane(VerticalScroll):
289
478
  def finalize_assistant_message(self, final: str | None = None) -> None:
290
479
  """Flush any buffered delta and close the message."""
291
480
  self._flush_pending_delta()
292
- if self._active_assistant is not None and final is not None:
293
- self._active_assistant.buffer = final
481
+ widget = self._active_assistant
482
+ if widget is not None:
483
+ if final is not None:
484
+ widget.buffer = final
485
+ # Cancel any in-flight async markdown render and paint the
486
+ # authoritative final markdown synchronously, so the last
487
+ # frame the user sees is the finished bubble — no flash
488
+ # between the async render that was mid-flight and the close.
489
+ widget.finalize_render()
294
490
  self._active_assistant = None
295
- self.scroll_end(animate=False)
296
491
 
297
492
  def add_tool_call(self, *, tool_id: str, label: str) -> None:
298
493
  """Emit an inline 'in-progress' tool entry."""
299
494
  widget = ChatMessageWidget(role="tool", initial=label, tool_state="pending")
300
495
  self._tool_widgets[tool_id] = widget
301
496
  self.mount(widget)
302
- self.scroll_end(animate=False)
303
497
 
304
498
  def complete_tool_call(
305
499
  self, *, tool_id: str, label: str | None = None, duration_ms: float = 0.0
@@ -312,7 +506,6 @@ class ChatPane(VerticalScroll):
312
506
  role="tool", initial=(label or tool_id), tool_state="done"
313
507
  )
314
508
  self.mount(widget)
315
- self.scroll_end(animate=False)
316
509
  return
317
510
  # Update label if caller gave a richer one; flip state classes.
318
511
  if label:
@@ -333,8 +526,13 @@ class ChatPane(VerticalScroll):
333
526
  self._active_assistant.buffer + self._pending_delta
334
527
  )
335
528
  self._pending_delta = ""
336
- self.scroll_end(animate=False)
337
529
 
338
530
  def _close_active_assistant(self) -> None:
339
531
  self._flush_pending_delta()
532
+ if self._active_assistant is not None:
533
+ # Finalize the previous bubble before losing the reference —
534
+ # otherwise its async render task may linger and try to
535
+ # update an orphaned widget (no visible failure, just wasted
536
+ # work and a lingering warning in the debug log).
537
+ self._active_assistant.finalize_render()
340
538
  self._active_assistant = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.36.0
3
+ Version: 0.38.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
@@ -144,6 +144,7 @@ tests/test_plugins.py
144
144
  tests/test_providers.py
145
145
  tests/test_ranker.py
146
146
  tests/test_reasoning.py
147
+ tests/test_runner_interrupt.py
147
148
  tests/test_runner_recovery.py
148
149
  tests/test_runtime.py
149
150
  tests/test_select.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aru-code"
7
- version = "0.36.0"
7
+ version = "0.38.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"