aru-code 0.52.0__tar.gz → 0.53.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 (211) hide show
  1. {aru_code-0.52.0/aru_code.egg-info → aru_code-0.53.0}/PKG-INFO +1 -1
  2. aru_code-0.53.0/aru/__init__.py +1 -0
  3. aru_code-0.53.0/aru/_debug/__init__.py +6 -0
  4. aru_code-0.53.0/aru/_debug/analyze_trace.py +328 -0
  5. aru_code-0.53.0/aru/_debug/loop_tracer.py +329 -0
  6. {aru_code-0.52.0 → aru_code-0.53.0}/aru/streaming.py +9 -0
  7. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/app.py +139 -2
  8. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/chat.py +51 -16
  9. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/prompt_area.py +7 -3
  10. {aru_code-0.52.0 → aru_code-0.53.0/aru_code.egg-info}/PKG-INFO +1 -1
  11. {aru_code-0.52.0 → aru_code-0.53.0}/aru_code.egg-info/SOURCES.txt +3 -0
  12. {aru_code-0.52.0 → aru_code-0.53.0}/pyproject.toml +1 -1
  13. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_chat_scrollable.py +31 -0
  14. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_copy.py +91 -0
  15. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_input_behaviour.py +41 -3
  16. aru_code-0.52.0/aru/__init__.py +0 -1
  17. {aru_code-0.52.0 → aru_code-0.53.0}/LICENSE +0 -0
  18. {aru_code-0.52.0 → aru_code-0.53.0}/README.md +0 -0
  19. {aru_code-0.52.0 → aru_code-0.53.0}/aru/agent_factory.py +0 -0
  20. {aru_code-0.52.0 → aru_code-0.53.0}/aru/agents/__init__.py +0 -0
  21. {aru_code-0.52.0 → aru_code-0.53.0}/aru/agents/base.py +0 -0
  22. {aru_code-0.52.0 → aru_code-0.53.0}/aru/agents/catalog.py +0 -0
  23. {aru_code-0.52.0 → aru_code-0.53.0}/aru/agents/planner.py +0 -0
  24. {aru_code-0.52.0 → aru_code-0.53.0}/aru/cache_patch.py +0 -0
  25. {aru_code-0.52.0 → aru_code-0.53.0}/aru/checkpoints.py +0 -0
  26. {aru_code-0.52.0 → aru_code-0.53.0}/aru/cli.py +0 -0
  27. {aru_code-0.52.0 → aru_code-0.53.0}/aru/commands.py +0 -0
  28. {aru_code-0.52.0 → aru_code-0.53.0}/aru/completers.py +0 -0
  29. {aru_code-0.52.0 → aru_code-0.53.0}/aru/config.py +0 -0
  30. {aru_code-0.52.0 → aru_code-0.53.0}/aru/context.py +0 -0
  31. {aru_code-0.52.0 → aru_code-0.53.0}/aru/display.py +0 -0
  32. {aru_code-0.52.0 → aru_code-0.53.0}/aru/doom_loop.py +0 -0
  33. {aru_code-0.52.0 → aru_code-0.53.0}/aru/events.py +0 -0
  34. {aru_code-0.52.0 → aru_code-0.53.0}/aru/format/__init__.py +0 -0
  35. {aru_code-0.52.0 → aru_code-0.53.0}/aru/format/manager.py +0 -0
  36. {aru_code-0.52.0 → aru_code-0.53.0}/aru/format/runner.py +0 -0
  37. {aru_code-0.52.0 → aru_code-0.53.0}/aru/history_blocks.py +0 -0
  38. {aru_code-0.52.0 → aru_code-0.53.0}/aru/lsp/__init__.py +0 -0
  39. {aru_code-0.52.0 → aru_code-0.53.0}/aru/lsp/client.py +0 -0
  40. {aru_code-0.52.0 → aru_code-0.53.0}/aru/lsp/manager.py +0 -0
  41. {aru_code-0.52.0 → aru_code-0.53.0}/aru/lsp/protocol.py +0 -0
  42. {aru_code-0.52.0 → aru_code-0.53.0}/aru/memory/__init__.py +0 -0
  43. {aru_code-0.52.0 → aru_code-0.53.0}/aru/memory/extractor.py +0 -0
  44. {aru_code-0.52.0 → aru_code-0.53.0}/aru/memory/loader.py +0 -0
  45. {aru_code-0.52.0 → aru_code-0.53.0}/aru/memory/store.py +0 -0
  46. {aru_code-0.52.0 → aru_code-0.53.0}/aru/permissions.py +0 -0
  47. {aru_code-0.52.0 → aru_code-0.53.0}/aru/plugin_cache.py +0 -0
  48. {aru_code-0.52.0 → aru_code-0.53.0}/aru/plugins/__init__.py +0 -0
  49. {aru_code-0.52.0 → aru_code-0.53.0}/aru/plugins/custom_tools.py +0 -0
  50. {aru_code-0.52.0 → aru_code-0.53.0}/aru/plugins/hooks.py +0 -0
  51. {aru_code-0.52.0 → aru_code-0.53.0}/aru/plugins/manager.py +0 -0
  52. {aru_code-0.52.0 → aru_code-0.53.0}/aru/plugins/tool_api.py +0 -0
  53. {aru_code-0.52.0 → aru_code-0.53.0}/aru/providers.py +0 -0
  54. {aru_code-0.52.0 → aru_code-0.53.0}/aru/runner.py +0 -0
  55. {aru_code-0.52.0 → aru_code-0.53.0}/aru/runtime.py +0 -0
  56. {aru_code-0.52.0 → aru_code-0.53.0}/aru/select.py +0 -0
  57. {aru_code-0.52.0 → aru_code-0.53.0}/aru/session.py +0 -0
  58. {aru_code-0.52.0 → aru_code-0.53.0}/aru/sinks.py +0 -0
  59. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tool_policy.py +0 -0
  60. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/__init__.py +0 -0
  61. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/_diff.py +0 -0
  62. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/_shared.py +0 -0
  63. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/apply_patch.py +0 -0
  64. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/apply_patch_prompt.txt +0 -0
  65. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/ast_tools.py +0 -0
  66. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/codebase.py +0 -0
  67. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/delegate.py +0 -0
  68. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/delegate_prompt.txt +0 -0
  69. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/file_ops.py +0 -0
  70. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/gitignore.py +0 -0
  71. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/lsp.py +0 -0
  72. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/mcp_client.py +0 -0
  73. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/memory_tool.py +0 -0
  74. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/plan_mode.py +0 -0
  75. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/ranker.py +0 -0
  76. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/registry.py +0 -0
  77. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/search.py +0 -0
  78. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/shell.py +0 -0
  79. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/skill.py +0 -0
  80. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/tasklist.py +0 -0
  81. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/web.py +0 -0
  82. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tools/worktree.py +0 -0
  83. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/__init__.py +0 -0
  84. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/log_bridge.py +0 -0
  85. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/notifications.py +0 -0
  86. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/sanitize.py +0 -0
  87. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/__init__.py +0 -0
  88. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/choice.py +0 -0
  89. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/confirm.py +0 -0
  90. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/keymap.py +0 -0
  91. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/search.py +0 -0
  92. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/session_picker.py +0 -0
  93. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/screens/text_input.py +0 -0
  94. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/sinks.py +0 -0
  95. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/slash_bridge.py +0 -0
  96. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/themes.py +0 -0
  97. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/ui.py +0 -0
  98. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/__init__.py +0 -0
  99. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/completer.py +0 -0
  100. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/context_pane.py +0 -0
  101. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/file_link.py +0 -0
  102. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/header.py +0 -0
  103. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/inline_choice.py +0 -0
  104. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/loaded_pane.py +0 -0
  105. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/prompt_queue.py +0 -0
  106. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/status.py +0 -0
  107. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/subagent_panel.py +0 -0
  108. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/tasklist_panel.py +0 -0
  109. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/thinking.py +0 -0
  110. {aru_code-0.52.0 → aru_code-0.53.0}/aru/tui/widgets/tools.py +0 -0
  111. {aru_code-0.52.0 → aru_code-0.53.0}/aru/ui.py +0 -0
  112. {aru_code-0.52.0 → aru_code-0.53.0}/aru_code.egg-info/dependency_links.txt +0 -0
  113. {aru_code-0.52.0 → aru_code-0.53.0}/aru_code.egg-info/entry_points.txt +0 -0
  114. {aru_code-0.52.0 → aru_code-0.53.0}/aru_code.egg-info/requires.txt +0 -0
  115. {aru_code-0.52.0 → aru_code-0.53.0}/aru_code.egg-info/top_level.txt +0 -0
  116. {aru_code-0.52.0 → aru_code-0.53.0}/setup.cfg +0 -0
  117. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_agents_base.py +0 -0
  118. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_agents_md_coverage.py +0 -0
  119. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_apply_patch.py +0 -0
  120. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_async_tool_permission.py +0 -0
  121. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cache_patch_metrics.py +0 -0
  122. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cache_patch_stop_reason.py +0 -0
  123. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_catalog.py +0 -0
  124. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_checkpoints.py +0 -0
  125. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli.py +0 -0
  126. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_advanced.py +0 -0
  127. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_base.py +0 -0
  128. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_completers.py +0 -0
  129. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_new.py +0 -0
  130. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_run_cli.py +0 -0
  131. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_session.py +0 -0
  132. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cli_shell.py +0 -0
  133. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_codebase.py +0 -0
  134. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_confabulation_regression.py +0 -0
  135. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_config.py +0 -0
  136. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_context.py +0 -0
  137. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_context_pane.py +0 -0
  138. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_cwd_awareness.py +0 -0
  139. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_delegate.py +0 -0
  140. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_doom_loop.py +0 -0
  141. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_events_backward_compat.py +0 -0
  142. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_events_schema.py +0 -0
  143. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_fork_ctx_concurrency.py +0 -0
  144. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_format.py +0 -0
  145. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_gitignore.py +0 -0
  146. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_guardrails_scenarios.py +0 -0
  147. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_invoke_skill.py +0 -0
  148. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_invoked_skills.py +0 -0
  149. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_loaded_pane_path.py +0 -0
  150. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_lsp.py +0 -0
  151. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_lsp_rename.py +0 -0
  152. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_main.py +0 -0
  153. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_markdown_to_text.py +0 -0
  154. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_mcp_client.py +0 -0
  155. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_mcp_health.py +0 -0
  156. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_memory.py +0 -0
  157. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_memory_tool.py +0 -0
  158. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_microcompact.py +0 -0
  159. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_permissions.py +0 -0
  160. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_plan_mode_refactor.py +0 -0
  161. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_plugin_cache.py +0 -0
  162. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_plugin_errors.py +0 -0
  163. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_plugin_hooks_v2.py +0 -0
  164. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_plugins.py +0 -0
  165. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_providers.py +0 -0
  166. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_ranker.py +0 -0
  167. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_reasoning.py +0 -0
  168. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_runner_interrupt.py +0 -0
  169. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_runner_recovery.py +0 -0
  170. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_runtime.py +0 -0
  171. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_select.py +0 -0
  172. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_session_free_cost.py +0 -0
  173. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_skill_disallowed_tools.py +0 -0
  174. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_status_breakdown.py +0 -0
  175. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_status_cost.py +0 -0
  176. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_streaming_sink.py +0 -0
  177. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_subagent_tool_events.py +0 -0
  178. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tasklist.py +0 -0
  179. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_thread_tool_timeout.py +0 -0
  180. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tool_policy.py +0 -0
  181. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_truncation_marker.py +0 -0
  182. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_app_boot.py +0 -0
  183. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_bindings.py +0 -0
  184. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_bus_flow.py +0 -0
  185. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_chat.py +0 -0
  186. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_chat_adversarial.py +0 -0
  187. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_completer.py +0 -0
  188. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_completer_dynamic.py +0 -0
  189. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_error_display.py +0 -0
  190. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_file_link.py +0 -0
  191. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_layer12_recovery.py +0 -0
  192. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_layer13_recovery.py +0 -0
  193. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_mention_expand.py +0 -0
  194. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_modals.py +0 -0
  195. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_mode_cycle.py +0 -0
  196. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_native_selection.py +0 -0
  197. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_permission_flow.py +0 -0
  198. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_plan_task_render.py +0 -0
  199. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_prompt_queue.py +0 -0
  200. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_shell_bang.py +0 -0
  201. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_sidebar_toggle.py +0 -0
  202. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_slash_bridge.py +0 -0
  203. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_slash_model.py +0 -0
  204. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_snapshot_smoke.py +0 -0
  205. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_subagent_panel.py +0 -0
  206. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_theme.py +0 -0
  207. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_thinking_and_boot.py +0 -0
  208. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_tui_widgets_visual.py +0 -0
  209. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_ui_adapter.py +0 -0
  210. {aru_code-0.52.0 → aru_code-0.53.0}/tests/test_worktree.py +0 -0
  211. {aru_code-0.52.0 → aru_code-0.53.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.52.0
3
+ Version: 0.53.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.53.0"
@@ -0,0 +1,6 @@
1
+ """Debug instrumentation package — gated by env vars, zero cost when off.
2
+
3
+ See ``loop_tracer.py`` for the Ctrl+C / loop-saturation tracer
4
+ (``ARU_DEBUG_LOOP=1``). New tracers go here as siblings; each owns its
5
+ own env var and log file under ``~/.aru/``.
6
+ """
@@ -0,0 +1,328 @@
1
+ """Analyze ``~/.aru/loop-trace.log`` and answer the decision tree from
2
+ ``docs/aru/2026-04-30-ctrlc-streaming-plan.md`` Fase 3.
3
+
4
+ Usage::
5
+
6
+ python -m aru._debug.analyze_trace [path]
7
+
8
+ Default path: ``~/.aru/loop-trace.log``. Pass an explicit path to
9
+ analyse a different file (e.g. one shipped from another machine).
10
+
11
+ The analyser is intentionally simple — `awk`-style line parsing,
12
+ bucketed counters, and a fixed set of questions. Any pattern more
13
+ complex than what this script captures should be added as a new
14
+ section here, not a separate ad-hoc script.
15
+
16
+ Output sections:
17
+
18
+ STATISTICS — summary of every event kind that appeared, with
19
+ count and (where relevant) max duration.
20
+ HOTSPOTS — top-10 ``loop_blocked`` entries sorted by gap.
21
+ CTRL_C — for each Ctrl+C key press detected at
22
+ ``driver.process_message``, the latency to
23
+ ``app._post_message`` and ``action_ctrl_c``.
24
+ VERDICT — direct readout of the decision tree (P1/P2/P3 or
25
+ continue-investigating).
26
+
27
+ Designed to be idempotent and zero-side-effect — never writes back to
28
+ the log.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import sys
35
+ from collections import defaultdict
36
+ from dataclasses import dataclass, field
37
+
38
+
39
+ @dataclass
40
+ class Event:
41
+ ts_ms: int
42
+ thread: str
43
+ name: str
44
+ detail: str
45
+
46
+ @classmethod
47
+ def parse(cls, line: str) -> "Event | None":
48
+ if line.startswith("#") or not line.strip():
49
+ return None
50
+ parts = line.rstrip("\n").split(",", 3)
51
+ if len(parts) < 3:
52
+ return None
53
+ try:
54
+ ts = int(parts[0])
55
+ except ValueError:
56
+ return None
57
+ thread = parts[1]
58
+ name = parts[2]
59
+ detail = parts[3] if len(parts) > 3 else ""
60
+ return cls(ts_ms=ts, thread=thread, name=name, detail=detail)
61
+
62
+
63
+ def _parse_kv(detail: str) -> dict[str, str]:
64
+ out: dict[str, str] = {}
65
+ for tok in detail.split():
66
+ if "=" in tok:
67
+ k, _, v = tok.partition("=")
68
+ out[k] = v
69
+ return out
70
+
71
+
72
+ def _detail_int(detail: str, key: str) -> int | None:
73
+ kv = _parse_kv(detail)
74
+ if key not in kv:
75
+ return None
76
+ try:
77
+ return int(float(kv[key]))
78
+ except ValueError:
79
+ return None
80
+
81
+
82
+ @dataclass
83
+ class Stats:
84
+ counts: dict[str, int] = field(default_factory=lambda: defaultdict(int))
85
+ max_gap_ms_loop: int = 0
86
+ loop_blocked_top: list[Event] = field(default_factory=list)
87
+ finalize_render_max_ms: int = 0
88
+ finalize_render_calls: int = 0
89
+ finalize_render_total_ms: int = 0
90
+ stream_bursts: list[Event] = field(default_factory=list)
91
+ ctrl_c_press: list[Event] = field(default_factory=list)
92
+ post_messages_ctrl_c: list[Event] = field(default_factory=list)
93
+ action_ctrl_c: list[Event] = field(default_factory=list)
94
+
95
+
96
+ def _is_ctrl_c(detail: str) -> bool:
97
+ kv = _parse_kv(detail)
98
+ return kv.get("key") == "ctrl+c"
99
+
100
+
101
+ def collect(events: list[Event]) -> Stats:
102
+ s = Stats()
103
+ for e in events:
104
+ s.counts[e.name] += 1
105
+ if e.name == "loop_blocked":
106
+ gap = _detail_int(e.detail, "gap_ms") or 0
107
+ s.max_gap_ms_loop = max(s.max_gap_ms_loop, gap)
108
+ s.loop_blocked_top.append(e)
109
+ elif e.name == "loop_tick":
110
+ gap = _detail_int(e.detail, "gap_ms") or 0
111
+ s.max_gap_ms_loop = max(s.max_gap_ms_loop, gap)
112
+ elif e.name == "finalize_render":
113
+ dt = _detail_int(e.detail, "dt_ms") or 0
114
+ s.finalize_render_calls += 1
115
+ s.finalize_render_total_ms += dt
116
+ s.finalize_render_max_ms = max(s.finalize_render_max_ms, dt)
117
+ elif e.name == "stream.event_burst":
118
+ s.stream_bursts.append(e)
119
+ elif e.name == "driver.process_message":
120
+ if _is_ctrl_c(e.detail):
121
+ s.ctrl_c_press.append(e)
122
+ elif e.name == "app._post_message":
123
+ if _is_ctrl_c(e.detail):
124
+ s.post_messages_ctrl_c.append(e)
125
+ elif e.name == "action_ctrl_c":
126
+ s.action_ctrl_c.append(e)
127
+
128
+ s.loop_blocked_top.sort(
129
+ key=lambda e: _detail_int(e.detail, "gap_ms") or 0, reverse=True
130
+ )
131
+ s.loop_blocked_top = s.loop_blocked_top[:10]
132
+ return s
133
+
134
+
135
+ def _correlate_ctrl_c(s: Stats) -> list[dict]:
136
+ """Match each ``driver.process_message ctrl+c`` to the next
137
+ ``app._post_message ctrl+c`` and ``action_ctrl_c`` after it.
138
+
139
+ Returns one dict per press with latencies in ms.
140
+ """
141
+ out: list[dict] = []
142
+ for press in s.ctrl_c_press:
143
+ next_post = next(
144
+ (p for p in s.post_messages_ctrl_c if p.ts_ms >= press.ts_ms),
145
+ None,
146
+ )
147
+ next_action = next(
148
+ (a for a in s.action_ctrl_c if a.ts_ms >= press.ts_ms),
149
+ None,
150
+ )
151
+ out.append(
152
+ {
153
+ "press_ms": press.ts_ms,
154
+ "thread_seen": press.thread,
155
+ "post_lag_ms": (
156
+ next_post.ts_ms - press.ts_ms if next_post else None
157
+ ),
158
+ "action_lag_ms": (
159
+ next_action.ts_ms - press.ts_ms if next_action else None
160
+ ),
161
+ }
162
+ )
163
+ return out
164
+
165
+
166
+ def _verdict(s: Stats, presses: list[dict]) -> list[str]:
167
+ out: list[str] = []
168
+ if not presses:
169
+ out.append("No Ctrl+C key events recorded.")
170
+ out.append("-> Either the user did not press Ctrl+C during this trace,")
171
+ out.append(" or the Textual input thread (P1) is wedged before")
172
+ out.append(" ``Driver.process_message`` is reached. If the user did")
173
+ out.append(" press Ctrl+C: P1 — investigate EventMonitor / ConIn read.")
174
+ return out
175
+
176
+ for i, p in enumerate(presses, 1):
177
+ out.append(f"Press #{i} at ts={p['press_ms']}ms (thread={p['thread_seen']}):")
178
+ post = p["post_lag_ms"]
179
+ action = p["action_lag_ms"]
180
+
181
+ if post is None:
182
+ out.append(
183
+ " P2/P3 — driver saw the key, but ``app._post_message`` "
184
+ "never fired."
185
+ )
186
+ out.append(
187
+ " -> loop saturated for the rest of the trace. Look at "
188
+ "loop_blocked HOTSPOTS at this timestamp."
189
+ )
190
+ continue
191
+
192
+ out.append(f" press -> app._post_message: {post}ms")
193
+ if post > 500:
194
+ out.append(
195
+ " P2 — loop took >500ms to drain the posted callback. "
196
+ "Saturation is the dominant cause."
197
+ )
198
+ elif post > 50:
199
+ out.append(
200
+ " Borderline — pump latency >50ms; check loop_blocked "
201
+ "near this timestamp."
202
+ )
203
+ else:
204
+ out.append(" pump latency healthy.")
205
+
206
+ if action is None:
207
+ out.append(
208
+ " P3 — pump received but action_ctrl_c never dispatched. "
209
+ "Check Screen.dispatch."
210
+ )
211
+ continue
212
+ out.append(f" press -> action_ctrl_c: {action}ms")
213
+ if action > 500:
214
+ out.append(
215
+ " P3 — pump dispatch is the bottleneck (likely behind a "
216
+ "queue of expensive events)."
217
+ )
218
+ elif action - (post or 0) > 100:
219
+ out.append(
220
+ " Pump->action handoff is slow; suspect heavy event "
221
+ "ahead of Key in the queue."
222
+ )
223
+
224
+ return out
225
+
226
+
227
+ def _suggest_fix(s: Stats) -> list[str]:
228
+ out: list[str] = []
229
+ if s.finalize_render_max_ms > 200:
230
+ out.append(
231
+ f"finalize_render max {s.finalize_render_max_ms}ms across "
232
+ f"{s.finalize_render_calls} calls "
233
+ f"(total {s.finalize_render_total_ms}ms)."
234
+ )
235
+ out.append(
236
+ "-> C3 candidate: move finalize_render off-thread "
237
+ "(asyncio.to_thread). One-file change in chat.py."
238
+ )
239
+
240
+ fast_bursts = [
241
+ b
242
+ for b in s.stream_bursts
243
+ if (_detail_int(b.detail, "dt_ms") or 999) < 5
244
+ ]
245
+ if fast_bursts:
246
+ out.append(
247
+ f"{len(fast_bursts)} stream bursts of 16 events in <5ms — "
248
+ f"hot-loop without yield."
249
+ )
250
+ out.append(
251
+ "-> C1 candidate: ``await asyncio.sleep(0)`` every N events "
252
+ "in streaming.py. One-line change."
253
+ )
254
+
255
+ if not out:
256
+ out.append(
257
+ "No obvious culprit in C1/C3. New round of instrumentation "
258
+ "needed (Compositor render hooks, paint cost)."
259
+ )
260
+ return out
261
+
262
+
263
+ def main(argv: list[str]) -> int:
264
+ # Windows cp1252 stdout chokes on em-dashes / arrows in our prose
265
+ # — switch to UTF-8 so the analyser can run anywhere without
266
+ # truncating the report.
267
+ try:
268
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
269
+ except Exception:
270
+ pass
271
+ path = argv[1] if len(argv) > 1 else os.path.expanduser("~/.aru/loop-trace.log")
272
+ if not os.path.exists(path):
273
+ print(f"trace file not found: {path}", file=sys.stderr)
274
+ return 2
275
+ with open(path, encoding="utf-8") as fh:
276
+ events = []
277
+ for line in fh:
278
+ ev = Event.parse(line)
279
+ if ev is not None:
280
+ events.append(ev)
281
+
282
+ if not events:
283
+ print("trace file has no events", file=sys.stderr)
284
+ return 2
285
+
286
+ s = collect(events)
287
+ presses = _correlate_ctrl_c(s)
288
+
289
+ print("=" * 72)
290
+ print(f"Trace: {path}")
291
+ print(f"Events: {len(events)} ({events[0].ts_ms}ms -> {events[-1].ts_ms}ms)")
292
+ print("=" * 72)
293
+
294
+ print("\n--- STATISTICS ---")
295
+ for name in sorted(s.counts):
296
+ print(f" {name:<32} {s.counts[name]}")
297
+ print(f" max loop gap: {s.max_gap_ms_loop}ms")
298
+ print(
299
+ f" finalize_render: max={s.finalize_render_max_ms}ms "
300
+ f"total={s.finalize_render_total_ms}ms "
301
+ f"calls={s.finalize_render_calls}"
302
+ )
303
+
304
+ print("\n--- HOTSPOTS (top 10 loop_blocked) ---")
305
+ for e in s.loop_blocked_top:
306
+ print(f" ts={e.ts_ms:>8}ms thread={e.thread:<20} {e.detail}")
307
+
308
+ print("\n--- CTRL_C ---")
309
+ for p in presses:
310
+ print(
311
+ f" ts={p['press_ms']:>8}ms "
312
+ f"post_lag={p['post_lag_ms']}ms "
313
+ f"action_lag={p['action_lag_ms']}ms"
314
+ )
315
+
316
+ print("\n--- VERDICT ---")
317
+ for line in _verdict(s, presses):
318
+ print(f" {line}")
319
+
320
+ print("\n--- SUGGESTED FIX ---")
321
+ for line in _suggest_fix(s):
322
+ print(f" {line}")
323
+
324
+ return 0
325
+
326
+
327
+ if __name__ == "__main__":
328
+ sys.exit(main(sys.argv))
@@ -0,0 +1,329 @@
1
+ """Loop-saturation tracer for Ctrl+C-during-streaming investigation.
2
+
3
+ Activate with ``ARU_DEBUG_LOOP=1``. When off, every entry point is a
4
+ single ``if not _ENABLED: return`` so production cost is one branch.
5
+
6
+ Output: ``~/.aru/loop-trace.log`` — append-only CSV with one event per
7
+ line, columns ``timestamp_ms,thread,event,detail``. Suitable for
8
+ ``awk`` / pandas / spreadsheet import without parsing.
9
+
10
+ Six instrumentation points (see ``docs/aru/2026-04-30-ctrlc-streaming-plan.md``
11
+ Fase 1):
12
+
13
+ A. ``loop_tick`` / ``loop_blocked`` — heartbeat scheduled on the
14
+ main asyncio loop at 20 Hz. Gap > 200 ms yields a ``loop_blocked``
15
+ entry — direct evidence the loop was unable to drain a callback
16
+ for that long, regardless of why.
17
+
18
+ B. ``driver.process_message`` — every message the Textual input
19
+ thread parses, before it's posted to the App pump. Confirms the
20
+ input thread is alive and saw the keystroke. Logged from the
21
+ ``textual-input`` thread.
22
+
23
+ C. ``app._post_message`` — every message the App pump dequeues.
24
+ Confirms the ``run_coroutine_threadsafe`` callback that B
25
+ scheduled actually drained on the loop. Logged from the loop
26
+ thread.
27
+
28
+ D. ``action_ctrl_c`` — entry of the App's Ctrl+C handler. Confirms
29
+ binding dispatch reached our action.
30
+
31
+ E. ``stream.event_burst`` — sampled every N events inside the
32
+ ``async for event in agent.arun(...)`` loop. Detects rajadas of
33
+ events arriving without a yield between them.
34
+
35
+ F. ``finalize_render`` — duration of the synchronous full-buffer
36
+ markdown re-parse on the loop thread.
37
+
38
+ Also exposed: ``trace(event, detail)`` so ad-hoc probes can be added
39
+ during diagnosis without recompiling.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import os
45
+ import threading
46
+ import time
47
+ from typing import Any
48
+
49
+ # ── Activation gate ──────────────────────────────────────────────────
50
+
51
+ _ENABLED: bool = bool(os.environ.get("ARU_DEBUG_LOOP"))
52
+
53
+
54
+ def is_enabled() -> bool:
55
+ return _ENABLED
56
+
57
+
58
+ # ── File handle (lazy, line-buffered) ────────────────────────────────
59
+
60
+ _TRACE_PATH: str = os.path.expanduser("~/.aru/loop-trace.log")
61
+ _LOCK = threading.Lock()
62
+ _FILE: Any = None
63
+ _T0: float = time.monotonic()
64
+
65
+
66
+ def _now_ms() -> int:
67
+ return int((time.monotonic() - _T0) * 1000)
68
+
69
+
70
+ def trace(event: str, detail: str = "") -> None:
71
+ """Append one event to the trace log. No-op when disabled.
72
+
73
+ Format: ``ts_ms,thread,event,detail\\n``. Detail is allowed to
74
+ contain commas — analysis tools should split on the first three
75
+ commas only.
76
+ """
77
+ if not _ENABLED:
78
+ return
79
+ try:
80
+ ts_ms = _now_ms()
81
+ thread = threading.current_thread().name
82
+ line = f"{ts_ms},{thread},{event},{detail}\n"
83
+ with _LOCK:
84
+ global _FILE
85
+ if _FILE is None:
86
+ os.makedirs(os.path.dirname(_TRACE_PATH), exist_ok=True)
87
+ # Line-buffered so a hard kill still leaves a usable
88
+ # trace on disk. ``buffering=1`` is line-buffered for
89
+ # text mode in Python.
90
+ _FILE = open(_TRACE_PATH, "a", encoding="utf-8", buffering=1)
91
+ _FILE.write(
92
+ f"# === session start === pid={os.getpid()} "
93
+ f"t0_monotonic={_T0:.3f}\n"
94
+ )
95
+ _FILE.write(line)
96
+ except Exception:
97
+ # Never let the tracer crash the app.
98
+ pass
99
+
100
+
101
+ # ── (B/C) Textual monkey-patches ─────────────────────────────────────
102
+
103
+ _patches_installed = False
104
+
105
+
106
+ def install_textual_patches() -> None:
107
+ """Patch ``Driver.process_message`` and ``App._post_message``.
108
+
109
+ Idempotent — only patches once per process. Patches the *class*,
110
+ so it covers all driver/app instances created later. Safe to call
111
+ multiple times.
112
+
113
+ The patched functions log to the tracer then delegate to the
114
+ original. They never raise — a buggy tracer must not break the
115
+ app.
116
+ """
117
+ global _patches_installed
118
+ if not _ENABLED or _patches_installed:
119
+ return
120
+ try:
121
+ from textual.driver import Driver
122
+ from textual.app import App
123
+ except Exception:
124
+ return
125
+
126
+ try:
127
+ _orig_pm = Driver.process_message
128
+
129
+ def _patched_pm(self, message):
130
+ try:
131
+ trace(
132
+ "driver.process_message",
133
+ f"type={type(message).__name__} "
134
+ f"key={getattr(message, 'key', '-')}",
135
+ )
136
+ except Exception:
137
+ pass
138
+ return _orig_pm(self, message)
139
+
140
+ Driver.process_message = _patched_pm
141
+ except Exception:
142
+ pass
143
+
144
+ try:
145
+ _orig_post = App._post_message
146
+
147
+ async def _patched_post(self, message):
148
+ try:
149
+ trace(
150
+ "app._post_message",
151
+ f"type={type(message).__name__} "
152
+ f"key={getattr(message, 'key', '-')}",
153
+ )
154
+ except Exception:
155
+ pass
156
+ return await _orig_post(self, message)
157
+
158
+ App._post_message = _patched_post
159
+ except Exception:
160
+ pass
161
+
162
+ # WriterThread.stop instrumentation — investigates the Ctrl+Q
163
+ # "summary appears but terminal does not release" symptom. ``stop()``
164
+ # internally does ``put(None) + join()``; if the queue has thousands
165
+ # of pending writes, ``join()`` blocks until ConPTY drains them all,
166
+ # which is exactly the wedge shape the user reports.
167
+ try:
168
+ from textual.drivers._writer_thread import WriterThread
169
+ _orig_stop = WriterThread.stop
170
+
171
+ def _patched_stop(self):
172
+ qsize = self._queue.qsize() if hasattr(self._queue, "qsize") else -1
173
+ trace("writer_thread.stop", f"begin qsize={qsize}")
174
+ try:
175
+ return _orig_stop(self)
176
+ finally:
177
+ trace("writer_thread.stop", "end")
178
+
179
+ WriterThread.stop = _patched_stop
180
+ except Exception:
181
+ pass
182
+
183
+ # Sniff every WriterThread.write call for escapes that change the
184
+ # terminal's mode (alt-screen, mouse, bracketed paste, etc.). The
185
+ # "TUI invadida pelo terminal" symptom is consistent with one of
186
+ # these escapes leaking from a non-Textual source while mouse
187
+ # tracking remains enabled. Logging the *issuer* lets us pin which
188
+ # site sent ``\x1b[?1049l`` (leave alt-screen) at runtime.
189
+ #
190
+ # The sniff is pattern-based — only a handful of escapes are
191
+ # logged, so noise is bounded even at high write rates.
192
+ try:
193
+ import re as _re
194
+ _MODE_RE = _re.compile(
195
+ r"\x1b\[\?(1049|1000|1003|1006|1015|1004|2004|25)([hl])"
196
+ )
197
+ _orig_write = WriterThread.write
198
+
199
+ def _patched_write(self, text):
200
+ try:
201
+ if isinstance(text, str):
202
+ for m in _MODE_RE.finditer(text):
203
+ trace(
204
+ "term_mode_escape",
205
+ f"mode={m.group(1)} action={m.group(2)} "
206
+ f"sample={text[max(0, m.start()-8):m.end()+8]!r}",
207
+ )
208
+ except Exception:
209
+ pass
210
+ return _orig_write(self, text)
211
+
212
+ WriterThread.write = _patched_write
213
+ except Exception:
214
+ pass
215
+
216
+ _patches_installed = True
217
+
218
+
219
+ # ── (A) Heartbeat ────────────────────────────────────────────────────
220
+
221
+ _heartbeat_state: dict[str, Any] = {"last": 0.0, "running": False}
222
+
223
+
224
+ def start_heartbeat(loop) -> None:
225
+ """Begin a 20 Hz heartbeat on *loop*.
226
+
227
+ Each tick measures the wall-clock gap since the previous tick. A
228
+ healthy loop ticks every ~50 ms (the call_later interval); a
229
+ saturated loop ticks late, and the gap measures exactly how long
230
+ the loop was unable to run a callback.
231
+
232
+ Gap > 200 ms emits ``loop_blocked``; otherwise ``loop_tick``. The
233
+ heartbeat keeps itself alive via recursive ``call_later`` and
234
+ stops when ``stop_heartbeat`` flips the running flag.
235
+ """
236
+ if not _ENABLED:
237
+ return
238
+ _heartbeat_state["last"] = time.monotonic()
239
+ _heartbeat_state["running"] = True
240
+
241
+ def _tick() -> None:
242
+ if not _heartbeat_state["running"]:
243
+ return
244
+ now = time.monotonic()
245
+ gap_ms = (now - _heartbeat_state["last"]) * 1000
246
+ _heartbeat_state["last"] = now
247
+ if gap_ms > 200:
248
+ trace("loop_blocked", f"gap_ms={gap_ms:.0f}")
249
+ else:
250
+ trace("loop_tick", f"gap_ms={gap_ms:.0f}")
251
+ try:
252
+ loop.call_later(0.05, _tick)
253
+ except Exception:
254
+ _heartbeat_state["running"] = False
255
+
256
+ try:
257
+ loop.call_later(0.05, _tick)
258
+ except Exception:
259
+ _heartbeat_state["running"] = False
260
+
261
+
262
+ def stop_heartbeat() -> None:
263
+ _heartbeat_state["running"] = False
264
+
265
+
266
+ # ── (E) Stream hot-loop sampler ──────────────────────────────────────
267
+
268
+ class StreamSampler:
269
+ """Sampler used inside ``streaming.run_stream``'s ``async for``.
270
+
271
+ Counts events and emits ``stream.event_burst`` every ``every`` ticks
272
+ with the wall-clock duration since the previous emission. A burst
273
+ of 16 events in <5 ms means the loop processed 16 events back-to-back
274
+ with no IO yield — direct evidence of hot-loop saturation.
275
+
276
+ Used as a context-light counter (one int + one float per call); the
277
+ log emission is gated by ``every`` so the trace stays readable.
278
+ """
279
+
280
+ __slots__ = ("_n", "_t0", "_every")
281
+
282
+ def __init__(self, every: int = 16) -> None:
283
+ self._n = 0
284
+ self._t0 = time.monotonic()
285
+ self._every = every
286
+
287
+ def tick(self, event_kind: str = "") -> None:
288
+ if not _ENABLED:
289
+ return
290
+ self._n += 1
291
+ if self._n % self._every == 0:
292
+ now = time.monotonic()
293
+ dt_ms = (now - self._t0) * 1000
294
+ trace(
295
+ "stream.event_burst",
296
+ f"n={self._n} dt_ms={dt_ms:.0f} kind={event_kind}",
297
+ )
298
+ self._t0 = now
299
+
300
+
301
+ # ── (F) finalize_render timer (helper) ───────────────────────────────
302
+
303
+ class TimedSection:
304
+ """Context manager that logs the duration of a sync block.
305
+
306
+ Used in ``finalize_render`` and any other place we suspect of
307
+ blocking the loop synchronously. Emits ``<event> dt_ms=...`` on
308
+ exit even if the block raised, so the duration is recorded for
309
+ both success and failure paths.
310
+ """
311
+
312
+ __slots__ = ("_event", "_detail", "_t0")
313
+
314
+ def __init__(self, event: str, detail: str = "") -> None:
315
+ self._event = event
316
+ self._detail = detail
317
+ self._t0 = 0.0
318
+
319
+ def __enter__(self) -> "TimedSection":
320
+ if _ENABLED:
321
+ self._t0 = time.monotonic()
322
+ return self
323
+
324
+ def __exit__(self, exc_type, exc, tb) -> None:
325
+ if not _ENABLED:
326
+ return
327
+ dt_ms = (time.monotonic() - self._t0) * 1000
328
+ suffix = f" exc={exc_type.__name__}" if exc_type is not None else ""
329
+ trace(self._event, f"{self._detail} dt_ms={dt_ms:.0f}{suffix}")
@@ -199,11 +199,20 @@ async def run_stream(
199
199
  doom_loop_detector = DoomLoopDetector()
200
200
  import time as _time
201
201
 
202
+ # Loop-saturation tracer hook (off unless ``ARU_DEBUG_LOOP=1``).
203
+ # Counts events as they're processed and emits ``stream.event_burst``
204
+ # periodically so we can detect rajadas with no IO yield in between
205
+ # — the suspected hot-loop pattern that strands Ctrl+C during
206
+ # streaming. See ``docs/aru/2026-04-30-ctrlc-streaming-plan.md``.
207
+ from aru._debug.loop_tracer import StreamSampler as _StreamSampler
208
+ _stream_sampler = _StreamSampler(every=16)
209
+
202
210
  while True:
203
211
  reset_last_stop_reason()
204
212
  _stall_counter = 0
205
213
 
206
214
  async for event in agent.arun(current_input, **arun_kwargs):
215
+ _stream_sampler.tick(type(event).__name__)
207
216
  if isinstance(event, RunOutput):
208
217
  run_output = event
209
218
  state.run_output = event