aru-code 0.48.0__tar.gz → 0.52.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 (210) hide show
  1. {aru_code-0.48.0/aru_code.egg-info → aru_code-0.52.0}/PKG-INFO +1 -1
  2. aru_code-0.52.0/aru/__init__.py +1 -0
  3. {aru_code-0.48.0 → aru_code-0.52.0}/aru/config.py +23 -0
  4. {aru_code-0.48.0 → aru_code-0.52.0}/aru/display.py +1 -1
  5. aru_code-0.52.0/aru/doom_loop.py +137 -0
  6. {aru_code-0.48.0 → aru_code-0.52.0}/aru/events.py +56 -0
  7. {aru_code-0.48.0 → aru_code-0.52.0}/aru/plugins/hooks.py +6 -0
  8. {aru_code-0.48.0 → aru_code-0.52.0}/aru/streaming.py +102 -0
  9. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/delegate.py +43 -0
  10. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/tasklist.py +99 -10
  11. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/app.py +604 -187
  12. aru_code-0.52.0/aru/tui/notifications.py +238 -0
  13. aru_code-0.52.0/aru/tui/screens/__init__.py +17 -0
  14. aru_code-0.52.0/aru/tui/screens/keymap.py +160 -0
  15. aru_code-0.52.0/aru/tui/screens/session_picker.py +189 -0
  16. aru_code-0.52.0/aru/tui/themes.py +99 -0
  17. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/chat.py +50 -1
  18. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/completer.py +1 -0
  19. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/context_pane.py +1 -1
  20. aru_code-0.52.0/aru/tui/widgets/file_link.py +255 -0
  21. aru_code-0.52.0/aru/tui/widgets/prompt_area.py +329 -0
  22. aru_code-0.52.0/aru/tui/widgets/prompt_queue.py +145 -0
  23. aru_code-0.52.0/aru/tui/widgets/subagent_panel.py +274 -0
  24. aru_code-0.52.0/aru/tui/widgets/tasklist_panel.py +204 -0
  25. {aru_code-0.48.0 → aru_code-0.52.0/aru_code.egg-info}/PKG-INFO +1 -1
  26. {aru_code-0.48.0 → aru_code-0.52.0}/aru_code.egg-info/SOURCES.txt +16 -0
  27. {aru_code-0.48.0 → aru_code-0.52.0}/pyproject.toml +1 -1
  28. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_chat_scrollable.py +13 -2
  29. aru_code-0.52.0/tests/test_doom_loop.py +492 -0
  30. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_events_schema.py +17 -0
  31. aru_code-0.52.0/tests/test_subagent_tool_events.py +301 -0
  32. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_completer.py +18 -19
  33. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_copy.py +52 -7
  34. aru_code-0.52.0/tests/test_tui_file_link.py +108 -0
  35. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_input_behaviour.py +123 -69
  36. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_mode_cycle.py +4 -4
  37. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_permission_flow.py +2 -2
  38. aru_code-0.52.0/tests/test_tui_plan_task_render.py +127 -0
  39. aru_code-0.52.0/tests/test_tui_prompt_queue.py +109 -0
  40. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_shell_bang.py +18 -18
  41. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_slash_bridge.py +3 -3
  42. aru_code-0.52.0/tests/test_tui_subagent_panel.py +300 -0
  43. aru_code-0.52.0/tests/test_tui_theme.py +85 -0
  44. aru_code-0.48.0/aru/__init__.py +0 -1
  45. aru_code-0.48.0/aru/tui/screens/__init__.py +0 -8
  46. aru_code-0.48.0/tests/test_tui_plan_task_render.py +0 -95
  47. {aru_code-0.48.0 → aru_code-0.52.0}/LICENSE +0 -0
  48. {aru_code-0.48.0 → aru_code-0.52.0}/README.md +0 -0
  49. {aru_code-0.48.0 → aru_code-0.52.0}/aru/agent_factory.py +0 -0
  50. {aru_code-0.48.0 → aru_code-0.52.0}/aru/agents/__init__.py +0 -0
  51. {aru_code-0.48.0 → aru_code-0.52.0}/aru/agents/base.py +0 -0
  52. {aru_code-0.48.0 → aru_code-0.52.0}/aru/agents/catalog.py +0 -0
  53. {aru_code-0.48.0 → aru_code-0.52.0}/aru/agents/planner.py +0 -0
  54. {aru_code-0.48.0 → aru_code-0.52.0}/aru/cache_patch.py +0 -0
  55. {aru_code-0.48.0 → aru_code-0.52.0}/aru/checkpoints.py +0 -0
  56. {aru_code-0.48.0 → aru_code-0.52.0}/aru/cli.py +0 -0
  57. {aru_code-0.48.0 → aru_code-0.52.0}/aru/commands.py +0 -0
  58. {aru_code-0.48.0 → aru_code-0.52.0}/aru/completers.py +0 -0
  59. {aru_code-0.48.0 → aru_code-0.52.0}/aru/context.py +0 -0
  60. {aru_code-0.48.0 → aru_code-0.52.0}/aru/format/__init__.py +0 -0
  61. {aru_code-0.48.0 → aru_code-0.52.0}/aru/format/manager.py +0 -0
  62. {aru_code-0.48.0 → aru_code-0.52.0}/aru/format/runner.py +0 -0
  63. {aru_code-0.48.0 → aru_code-0.52.0}/aru/history_blocks.py +0 -0
  64. {aru_code-0.48.0 → aru_code-0.52.0}/aru/lsp/__init__.py +0 -0
  65. {aru_code-0.48.0 → aru_code-0.52.0}/aru/lsp/client.py +0 -0
  66. {aru_code-0.48.0 → aru_code-0.52.0}/aru/lsp/manager.py +0 -0
  67. {aru_code-0.48.0 → aru_code-0.52.0}/aru/lsp/protocol.py +0 -0
  68. {aru_code-0.48.0 → aru_code-0.52.0}/aru/memory/__init__.py +0 -0
  69. {aru_code-0.48.0 → aru_code-0.52.0}/aru/memory/extractor.py +0 -0
  70. {aru_code-0.48.0 → aru_code-0.52.0}/aru/memory/loader.py +0 -0
  71. {aru_code-0.48.0 → aru_code-0.52.0}/aru/memory/store.py +0 -0
  72. {aru_code-0.48.0 → aru_code-0.52.0}/aru/permissions.py +0 -0
  73. {aru_code-0.48.0 → aru_code-0.52.0}/aru/plugin_cache.py +0 -0
  74. {aru_code-0.48.0 → aru_code-0.52.0}/aru/plugins/__init__.py +0 -0
  75. {aru_code-0.48.0 → aru_code-0.52.0}/aru/plugins/custom_tools.py +0 -0
  76. {aru_code-0.48.0 → aru_code-0.52.0}/aru/plugins/manager.py +0 -0
  77. {aru_code-0.48.0 → aru_code-0.52.0}/aru/plugins/tool_api.py +0 -0
  78. {aru_code-0.48.0 → aru_code-0.52.0}/aru/providers.py +0 -0
  79. {aru_code-0.48.0 → aru_code-0.52.0}/aru/runner.py +0 -0
  80. {aru_code-0.48.0 → aru_code-0.52.0}/aru/runtime.py +0 -0
  81. {aru_code-0.48.0 → aru_code-0.52.0}/aru/select.py +0 -0
  82. {aru_code-0.48.0 → aru_code-0.52.0}/aru/session.py +0 -0
  83. {aru_code-0.48.0 → aru_code-0.52.0}/aru/sinks.py +0 -0
  84. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tool_policy.py +0 -0
  85. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/__init__.py +0 -0
  86. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/_diff.py +0 -0
  87. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/_shared.py +0 -0
  88. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/apply_patch.py +0 -0
  89. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/apply_patch_prompt.txt +0 -0
  90. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/ast_tools.py +0 -0
  91. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/codebase.py +0 -0
  92. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/delegate_prompt.txt +0 -0
  93. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/file_ops.py +0 -0
  94. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/gitignore.py +0 -0
  95. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/lsp.py +0 -0
  96. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/mcp_client.py +0 -0
  97. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/memory_tool.py +0 -0
  98. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/plan_mode.py +0 -0
  99. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/ranker.py +0 -0
  100. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/registry.py +0 -0
  101. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/search.py +0 -0
  102. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/shell.py +0 -0
  103. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/skill.py +0 -0
  104. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/web.py +0 -0
  105. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/worktree.py +0 -0
  106. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/__init__.py +0 -0
  107. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/log_bridge.py +0 -0
  108. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/sanitize.py +0 -0
  109. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/screens/choice.py +0 -0
  110. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/screens/confirm.py +0 -0
  111. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/screens/search.py +0 -0
  112. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/screens/text_input.py +0 -0
  113. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/sinks.py +0 -0
  114. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/slash_bridge.py +0 -0
  115. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/ui.py +0 -0
  116. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/__init__.py +0 -0
  117. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/header.py +0 -0
  118. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/inline_choice.py +0 -0
  119. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/loaded_pane.py +0 -0
  120. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/status.py +0 -0
  121. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/thinking.py +0 -0
  122. {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/tools.py +0 -0
  123. {aru_code-0.48.0 → aru_code-0.52.0}/aru/ui.py +0 -0
  124. {aru_code-0.48.0 → aru_code-0.52.0}/aru_code.egg-info/dependency_links.txt +0 -0
  125. {aru_code-0.48.0 → aru_code-0.52.0}/aru_code.egg-info/entry_points.txt +0 -0
  126. {aru_code-0.48.0 → aru_code-0.52.0}/aru_code.egg-info/requires.txt +0 -0
  127. {aru_code-0.48.0 → aru_code-0.52.0}/aru_code.egg-info/top_level.txt +0 -0
  128. {aru_code-0.48.0 → aru_code-0.52.0}/setup.cfg +0 -0
  129. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_agents_base.py +0 -0
  130. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_agents_md_coverage.py +0 -0
  131. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_apply_patch.py +0 -0
  132. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_async_tool_permission.py +0 -0
  133. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cache_patch_metrics.py +0 -0
  134. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cache_patch_stop_reason.py +0 -0
  135. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_catalog.py +0 -0
  136. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_checkpoints.py +0 -0
  137. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli.py +0 -0
  138. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_advanced.py +0 -0
  139. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_base.py +0 -0
  140. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_completers.py +0 -0
  141. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_new.py +0 -0
  142. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_run_cli.py +0 -0
  143. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_session.py +0 -0
  144. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_shell.py +0 -0
  145. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_codebase.py +0 -0
  146. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_confabulation_regression.py +0 -0
  147. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_config.py +0 -0
  148. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_context.py +0 -0
  149. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_context_pane.py +0 -0
  150. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cwd_awareness.py +0 -0
  151. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_delegate.py +0 -0
  152. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_events_backward_compat.py +0 -0
  153. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_fork_ctx_concurrency.py +0 -0
  154. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_format.py +0 -0
  155. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_gitignore.py +0 -0
  156. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_guardrails_scenarios.py +0 -0
  157. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_invoke_skill.py +0 -0
  158. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_invoked_skills.py +0 -0
  159. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_loaded_pane_path.py +0 -0
  160. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_lsp.py +0 -0
  161. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_lsp_rename.py +0 -0
  162. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_main.py +0 -0
  163. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_markdown_to_text.py +0 -0
  164. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_mcp_client.py +0 -0
  165. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_mcp_health.py +0 -0
  166. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_memory.py +0 -0
  167. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_memory_tool.py +0 -0
  168. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_microcompact.py +0 -0
  169. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_permissions.py +0 -0
  170. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_plan_mode_refactor.py +0 -0
  171. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_plugin_cache.py +0 -0
  172. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_plugin_errors.py +0 -0
  173. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_plugin_hooks_v2.py +0 -0
  174. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_plugins.py +0 -0
  175. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_providers.py +0 -0
  176. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_ranker.py +0 -0
  177. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_reasoning.py +0 -0
  178. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_runner_interrupt.py +0 -0
  179. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_runner_recovery.py +0 -0
  180. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_runtime.py +0 -0
  181. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_select.py +0 -0
  182. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_session_free_cost.py +0 -0
  183. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_skill_disallowed_tools.py +0 -0
  184. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_status_breakdown.py +0 -0
  185. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_status_cost.py +0 -0
  186. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_streaming_sink.py +0 -0
  187. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tasklist.py +0 -0
  188. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_thread_tool_timeout.py +0 -0
  189. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tool_policy.py +0 -0
  190. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_truncation_marker.py +0 -0
  191. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_app_boot.py +0 -0
  192. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_bindings.py +0 -0
  193. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_bus_flow.py +0 -0
  194. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_chat.py +0 -0
  195. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_chat_adversarial.py +0 -0
  196. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_completer_dynamic.py +0 -0
  197. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_error_display.py +0 -0
  198. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_layer12_recovery.py +0 -0
  199. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_layer13_recovery.py +0 -0
  200. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_mention_expand.py +0 -0
  201. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_modals.py +0 -0
  202. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_native_selection.py +0 -0
  203. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_sidebar_toggle.py +0 -0
  204. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_slash_model.py +0 -0
  205. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_snapshot_smoke.py +0 -0
  206. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_thinking_and_boot.py +0 -0
  207. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_widgets_visual.py +0 -0
  208. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_ui_adapter.py +0 -0
  209. {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_worktree.py +0 -0
  210. {aru_code-0.48.0 → aru_code-0.52.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.48.0
3
+ Version: 0.52.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.52.0"
@@ -180,6 +180,16 @@ class AgentConfig:
180
180
  # Formatter config per language (Tier 3 #1). Same shape as `lsp`, plus
181
181
  # an `enabled` top-level boolean to flip auto-format on/off in aggregate.
182
182
  format: dict[str, Any] = field(default_factory=dict)
183
+ # TUI theme name (one of the presets in `aru/tui/themes.py`). When unset
184
+ # the App keeps Textual's default. Hot-reloadable via the `/theme` slash
185
+ # command, but the persisted choice lives in `aru.json`.
186
+ theme: str = ""
187
+ # OS notification policy: "off" | "background" | "long" | "always".
188
+ # See `aru/tui/notifications.py` for the threshold logic.
189
+ notify: str = "background"
190
+ # Minimum turn duration (seconds) to ring the bell when `notify: long`
191
+ # or `notify: always`. Default 30s — short turns don't warrant a chime.
192
+ notify_threshold_sec: float = 30.0
183
193
 
184
194
  @property
185
195
  def has_instructions(self) -> bool:
@@ -545,6 +555,19 @@ def _apply_config_data(config: AgentConfig, data: dict, root: Path) -> None:
545
555
  config.lsp = data["lsp"]
546
556
  if "format" in data and isinstance(data["format"], dict):
547
557
  config.format = data["format"]
558
+ if "theme" in data and isinstance(data["theme"], str):
559
+ config.theme = data["theme"].strip()
560
+ if "notify" in data and isinstance(data["notify"], str):
561
+ nv = data["notify"].strip().lower()
562
+ if nv in ("off", "background", "long", "always"):
563
+ config.notify = nv
564
+ if "notify_threshold_sec" in data:
565
+ try:
566
+ v = float(data["notify_threshold_sec"])
567
+ if v > 0:
568
+ config.notify_threshold_sec = v
569
+ except (TypeError, ValueError):
570
+ pass
548
571
  if "instructions" in data and isinstance(data["instructions"], list):
549
572
  entries = [str(e) for e in data["instructions"] if isinstance(e, str)]
550
573
  config.rules_instructions = _resolve_instructions(entries, root)
@@ -25,7 +25,7 @@ aru_logo = """
25
25
  """
26
26
 
27
27
  neon_green = "#39ff14"
28
- shadow_green = "#042800"
28
+ shadow_green = "#073e00"
29
29
 
30
30
 
31
31
  def _build_logo_with_shadow(logo_text: str) -> Text:
@@ -0,0 +1,137 @@
1
+ """Doom-loop detection: catch agents stuck repeating identical tool calls.
2
+
3
+ A *doom-loop* is the agent invoking the same tool with the same arguments
4
+ N times in a row, with no intervening different call. The model has lost
5
+ the plot — typically because a tool kept failing and the model forgot it
6
+ already tried that exact thing — and without intervention the run will
7
+ just burn budget until the context window fills.
8
+
9
+ This module gives the runner a cheap, deterministic detector. The
10
+ heuristic mirrors OpenCode's ``session/processor.ts:188-211``:
11
+
12
+ * keep a sliding window of the last N (tool_name, sorted-args) pairs;
13
+ * when the window is full and every entry equals the latest one, fire.
14
+
15
+ When detection fires the runner pauses, asks the user "continue or
16
+ abort?", and either resets the buffer for that tool (allowing the model
17
+ a fresh chance) or aborts the run.
18
+
19
+ Threshold is **3** by default — same as OpenCode. Override per-process
20
+ via the ``ARU_DOOM_LOOP_THRESHOLD`` env var (must be ≥ 2; values below
21
+ fall back to the default to avoid pathologically eager prompts).
22
+
23
+ Args equality uses ``json.dumps(..., sort_keys=True)`` so two calls with
24
+ the same logical args but differing key order — ``{"a": 1, "b": 2}`` vs
25
+ ``{"b": 2, "a": 1}`` — are correctly treated as identical. ``default=str``
26
+ keeps non-JSON values (e.g. Path) from raising; the resulting string is
27
+ still a stable signature for equality.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ from collections import deque
35
+ from typing import Any
36
+
37
+
38
+ DEFAULT_THRESHOLD = 3
39
+ _ENV_VAR = "ARU_DOOM_LOOP_THRESHOLD"
40
+
41
+
42
+ def _stable_signature(tool_name: str, tool_args: Any) -> tuple[str, str]:
43
+ """Return a hashable equality signature for a tool invocation.
44
+
45
+ The args portion is a JSON dump with ``sort_keys=True`` so two calls
46
+ that differ only by key order in the args dict are treated as equal.
47
+ Non-JSON values (Paths, datetimes, etc.) are stringified so the dump
48
+ never raises — the goal is a stable signature, not a round-trippable
49
+ payload.
50
+ """
51
+ if isinstance(tool_args, dict):
52
+ try:
53
+ args_repr = json.dumps(tool_args, sort_keys=True, default=str)
54
+ except Exception:
55
+ # json.dumps can still fail on truly exotic values (e.g.
56
+ # circular refs). Fallback to repr — less stable but never
57
+ # raises, and the detector tolerates occasional mismatches.
58
+ args_repr = repr(sorted(tool_args.items()))
59
+ elif tool_args is None:
60
+ args_repr = "null"
61
+ else:
62
+ # Non-dict args (rare — Agno usually wraps in a dict) — just str.
63
+ args_repr = str(tool_args)
64
+ return (tool_name or "", args_repr)
65
+
66
+
67
+ def threshold_from_env() -> int:
68
+ """Read ``ARU_DOOM_LOOP_THRESHOLD`` or return the default.
69
+
70
+ Values < 2 fall back to the default — a threshold of 1 would fire on
71
+ the very first call which is meaningless, and a threshold of 0 makes
72
+ the deque() unbounded. Invalid values (non-int) also fall back.
73
+ """
74
+ raw = os.environ.get(_ENV_VAR)
75
+ if raw is None:
76
+ return DEFAULT_THRESHOLD
77
+ try:
78
+ v = int(raw)
79
+ except ValueError:
80
+ return DEFAULT_THRESHOLD
81
+ if v < 2:
82
+ return DEFAULT_THRESHOLD
83
+ return v
84
+
85
+
86
+ class DoomLoopDetector:
87
+ """Sliding-window detector for repeated identical tool calls.
88
+
89
+ Each ``record(tool_name, tool_args)`` call appends a signature to the
90
+ window and returns ``True`` iff the window is now full **and** every
91
+ entry in it is identical (i.e. the last N calls were the exact same
92
+ tool with the exact same args).
93
+
94
+ The detector is stateless beyond its window — there is no notion of
95
+ sessions or scopes. The runner instantiates one detector per turn of
96
+ the primary agent loop; sub-agents that run their own arun loop
97
+ (delegate.py) get their own detector via the same wiring.
98
+ """
99
+
100
+ def __init__(self, threshold: int | None = None) -> None:
101
+ self.threshold: int = threshold if threshold is not None else threshold_from_env()
102
+ self._recent: deque[tuple[str, str]] = deque(maxlen=self.threshold)
103
+
104
+ def record(self, tool_name: str, tool_args: Any) -> bool:
105
+ """Append a call's signature; return True if a doom-loop is now detected."""
106
+ sig = _stable_signature(tool_name, tool_args)
107
+ self._recent.append(sig)
108
+ if len(self._recent) < self.threshold:
109
+ return False
110
+ first = self._recent[0]
111
+ return all(s == first for s in self._recent)
112
+
113
+ def reset(self) -> None:
114
+ """Forget all recorded calls. Used after manual intervention."""
115
+ self._recent.clear()
116
+
117
+ def reset_for_tool(self, tool_name: str) -> None:
118
+ """Drop every entry whose tool_name equals *tool_name*.
119
+
120
+ Called after the user chooses *continue* on a doom-loop prompt:
121
+ the buffer is wiped for that specific tool so the very next call
122
+ doesn't immediately re-trigger the same prompt. Other tools'
123
+ history (which were not part of the loop) is preserved so a
124
+ secondary loop on a different tool still detects.
125
+ """
126
+ kept = [s for s in self._recent if s[0] != tool_name]
127
+ self._recent = deque(kept, maxlen=self.threshold)
128
+
129
+ def __len__(self) -> int: # convenience for tests
130
+ return len(self._recent)
131
+
132
+
133
+ __all__ = [
134
+ "DEFAULT_THRESHOLD",
135
+ "DoomLoopDetector",
136
+ "threshold_from_env",
137
+ ]
@@ -103,6 +103,31 @@ class SubagentCompleteEvent(BaseEvent):
103
103
  duration_ms: float = 0.0
104
104
 
105
105
 
106
+ class SubagentToolStartedEvent(BaseEvent):
107
+ """A tool call started inside a running sub-agent.
108
+
109
+ Distinct from ``tool.called`` — that event fires from the orchestrator's
110
+ own tool loop. ``subagent.tool.started`` fires from inside a delegated
111
+ sub-agent's stream and carries the parent ``task_id`` so the TUI's
112
+ SubagentPanel can update the right row.
113
+ """
114
+
115
+ event_type: Literal["subagent.tool.started"] = "subagent.tool.started"
116
+ task_id: str = "" # subagent's task_id (the row key)
117
+ tool_id: str = ""
118
+ tool_name: str = ""
119
+ tool_args_preview: str = "" # first ~80 chars of args for the row label
120
+
121
+
122
+ class SubagentToolCompletedEvent(BaseEvent):
123
+ event_type: Literal["subagent.tool.completed"] = "subagent.tool.completed"
124
+ task_id: str = ""
125
+ tool_id: str = ""
126
+ tool_name: str = ""
127
+ duration_ms: float = 0.0
128
+ error: str | None = None
129
+
130
+
106
131
  # ── Workspace / cwd ───────────────────────────────────────────────────
107
132
 
108
133
 
@@ -141,6 +166,29 @@ class PermissionModeChangedEvent(BaseEvent):
141
166
  # ── Intra-turn metrics ────────────────────────────────────────────────
142
167
 
143
168
 
169
+ class TasklistUpdatedEvent(BaseEvent):
170
+ """Published whenever ``create_task_list`` / ``update_task`` mutates
171
+ the executor's subtask list. Lets the TUI ``TasklistPanel`` render
172
+ a live sidebar without reading the chat for the Rich panel.
173
+
174
+ ``tasks`` is the full list (newest snapshot) — subscribers do not
175
+ need to maintain incremental state. Empty list = list cleared.
176
+ """
177
+
178
+ event_type: Literal["tasklist.updated"] = "tasklist.updated"
179
+ tasks: list[dict[str, Any]] = Field(default_factory=list)
180
+
181
+
182
+ class PlanUpdatedEvent(BaseEvent):
183
+ """Published when macro plan steps change (enter_plan_mode /
184
+ update_plan_step). Same shape contract as TasklistUpdatedEvent —
185
+ full snapshot, sidebar consumes directly.
186
+ """
187
+
188
+ event_type: Literal["plan.updated"] = "plan.updated"
189
+ steps: list[dict[str, Any]] = Field(default_factory=list)
190
+
191
+
144
192
  class MetricsUpdatedEvent(BaseEvent):
145
193
  """Published after each internal LLM API call (from ``cache_patch``).
146
194
 
@@ -176,11 +224,15 @@ AruEvent = Union[
176
224
  ToolCompletedEvent,
177
225
  SubagentStartEvent,
178
226
  SubagentCompleteEvent,
227
+ SubagentToolStartedEvent,
228
+ SubagentToolCompletedEvent,
179
229
  CwdChangedEvent,
180
230
  FileChangedEvent,
181
231
  PermissionDeniedEvent,
182
232
  PermissionModeChangedEvent,
183
233
  MetricsUpdatedEvent,
234
+ TasklistUpdatedEvent,
235
+ PlanUpdatedEvent,
184
236
  ]
185
237
 
186
238
 
@@ -196,11 +248,15 @@ EVENT_MODELS: dict[str, type[BaseEvent]] = {
196
248
  "tool.completed": ToolCompletedEvent,
197
249
  "subagent.start": SubagentStartEvent,
198
250
  "subagent.complete": SubagentCompleteEvent,
251
+ "subagent.tool.started": SubagentToolStartedEvent,
252
+ "subagent.tool.completed": SubagentToolCompletedEvent,
199
253
  "cwd.changed": CwdChangedEvent,
200
254
  "file.changed": FileChangedEvent,
201
255
  "permission.denied": PermissionDeniedEvent,
202
256
  "permission.mode.changed": PermissionModeChangedEvent,
203
257
  "metrics.updated": MetricsUpdatedEvent,
258
+ "tasklist.updated": TasklistUpdatedEvent,
259
+ "plan.updated": PlanUpdatedEvent,
204
260
  }
205
261
 
206
262
 
@@ -67,6 +67,8 @@ VALID_HOOKS = frozenset({
67
67
  # Sub-agent (Tier 2 #3)
68
68
  "subagent.start", # After delegate_task spawns a sub-agent
69
69
  "subagent.complete", # After sub-agent terminates (ok, error, cancelled)
70
+ "subagent.tool.started", # Inside a sub-agent: a tool call started
71
+ "subagent.tool.completed", # Inside a sub-agent: a tool call completed
70
72
 
71
73
  # Turn lifecycle (Tier 2 #3)
72
74
  "turn.start", # Beginning of runner.prompt for a new user turn
@@ -76,6 +78,10 @@ VALID_HOOKS = frozenset({
76
78
  "metrics.updated", # After every internal LLM API call (cache_patch);
77
79
  # lets the TUI refresh tokens/cost mid-turn so long
78
80
  # implementation runs don't sit silent for minutes.
81
+
82
+ # Tasklist / plan visibility (Tier 2 #6 sidebar)
83
+ "tasklist.updated", # create_task_list / update_task — full snapshot
84
+ "plan.updated", # enter_plan_mode / update_plan_step — full snapshot
79
85
  })
80
86
 
81
87
 
@@ -174,6 +174,7 @@ async def run_stream(
174
174
  )
175
175
  from aru.cache_patch import get_last_stop_reason, reset_last_stop_reason
176
176
  from aru.display import _format_tool_label
177
+ from aru.doom_loop import DoomLoopDetector
177
178
 
178
179
  state = StreamState()
179
180
  accumulated = ""
@@ -188,6 +189,14 @@ async def run_stream(
188
189
 
189
190
  # Track tool start times so the sink gets a duration on completion.
190
191
  tool_start_times: dict[str, float] = {}
192
+ # Cache args at started so the doom-loop detector and any other
193
+ # post-hoc check can read them at completed without depending on the
194
+ # provider always populating ``tool_args`` on the completed event.
195
+ tool_args_by_id: dict[str, Any] = {}
196
+ # Per-turn doom-loop detector — reset on each new run_stream call so
197
+ # repeating the same tool across separate user turns isn't a "loop".
198
+ # See ``aru/doom_loop.py`` for the heuristic.
199
+ doom_loop_detector = DoomLoopDetector()
191
200
  import time as _time
192
201
 
193
202
  while True:
@@ -229,6 +238,9 @@ async def run_stream(
229
238
  pending_tool_uses[tool_id] = assistant_blocks[-1]
230
239
 
231
240
  tool_start_times[tool_id] = _time.monotonic()
241
+ tool_args_by_id[tool_id] = (
242
+ tool_args if isinstance(tool_args, dict) else None
243
+ )
232
244
  sink.on_tool_started(
233
245
  tool_id=tool_id,
234
246
  tool_name=tool_name,
@@ -303,6 +315,35 @@ async def run_stream(
303
315
  label=tool_name, # sink caches its own label if needed
304
316
  )
305
317
 
318
+ # Doom-loop check — if the model just made the same call
319
+ # for the Nth time in a row, pause and ask the user. The
320
+ # detector is per-turn (constructed at run_stream entry)
321
+ # so a legitimate repeat across separate turns doesn't
322
+ # fire. See ``aru/doom_loop.py``.
323
+ tool_args_for_detector = tool_args_by_id.pop(tool_id, None)
324
+ if doom_loop_detector.record(tool_name, tool_args_for_detector):
325
+ if await _handle_doom_loop(
326
+ sink=sink,
327
+ tool_name=tool_name,
328
+ tool_args=tool_args_for_detector,
329
+ ):
330
+ # User chose continue — wipe history for this
331
+ # tool so the very next call doesn't immediately
332
+ # re-prompt. Other tools' history is preserved.
333
+ doom_loop_detector.reset_for_tool(tool_name)
334
+ else:
335
+ # User chose abort. Mark stalled, propagate the
336
+ # abort signal to any in-flight sub-agents, and
337
+ # break out of the stream — the outer recovery
338
+ # loop's stop-reason check will then exit.
339
+ state.stalled = True
340
+ try:
341
+ from aru.runtime import abort_current
342
+ abort_current()
343
+ except Exception:
344
+ pass
345
+ break
346
+
306
347
  # When the last active tool in the round completed, close it
307
348
  # and let the sink flush deferred renders (plan panel etc.).
308
349
  if not pending_tool_uses:
@@ -352,3 +393,64 @@ async def run_stream(
352
393
  state.accumulated = accumulated
353
394
  sink.on_stream_finished(final_content=accumulated)
354
395
  return state
396
+
397
+
398
+ async def _handle_doom_loop(
399
+ *,
400
+ sink: StreamSink,
401
+ tool_name: str,
402
+ tool_args: Any,
403
+ ) -> bool:
404
+ """Notify + prompt the user when a doom-loop fires. Returns True to continue.
405
+
406
+ Routed through ``ctx.ui.confirm`` so REPL gets the prompt_toolkit
407
+ yes/no and TUI gets a ``ConfirmModal`` — same code path as the
408
+ permission prompts. Wrapped in ``asyncio.to_thread`` because the UI
409
+ adapter is sync (it bridges to the Textual event loop internally via
410
+ ``call_from_thread`` and a ``threading.Event``); calling it directly
411
+ from the async loop would deadlock the TUI.
412
+ """
413
+ import asyncio
414
+ import json
415
+
416
+ try:
417
+ args_preview = json.dumps(
418
+ tool_args if isinstance(tool_args, dict) else {},
419
+ sort_keys=True, default=str,
420
+ )
421
+ except Exception:
422
+ args_preview = repr(tool_args)
423
+ if len(args_preview) > 120:
424
+ args_preview = args_preview[:117] + "..."
425
+
426
+ sink.notify(
427
+ f"Doom-loop detected: 3× identical {tool_name}({args_preview}). "
428
+ f"Pausing for user confirmation.",
429
+ style="bold yellow",
430
+ )
431
+
432
+ prompt = (
433
+ f"`{tool_name}` was called 3 times in a row with the same input "
434
+ f"({args_preview}). The agent may be stuck in a loop. Continue?"
435
+ )
436
+
437
+ try:
438
+ from aru.runtime import get_ctx
439
+ ctx = get_ctx()
440
+ except LookupError:
441
+ # No runtime ctx (test harness without init_ctx) — default to
442
+ # abort so the loop doesn't keep firing forever.
443
+ return False
444
+
445
+ ui = getattr(ctx, "ui", None)
446
+ if ui is None:
447
+ # No UI installed — same conservative default. abort.
448
+ return False
449
+
450
+ try:
451
+ return bool(await asyncio.to_thread(ui.confirm, prompt, False))
452
+ except Exception:
453
+ # If the prompt itself raises (timeout, missing app, etc.) we
454
+ # default to abort — the model is stuck and we can't ask the
455
+ # user, so continuing risks burning more budget for nothing.
456
+ return False
@@ -421,11 +421,23 @@ Do not create documentation files unless explicitly asked.
421
421
  "task": (task or "")[:500],
422
422
  })
423
423
 
424
+ from aru.runtime import _schedule_publish as _sched_t
424
425
  try:
425
426
  async for event in agent_instance.arun(task, stream=True, stream_events=True, yield_run_output=True):
426
427
  if is_aborted():
427
428
  _trace.status = "cancelled"
428
429
  _trace.ended_at = _time.monotonic()
430
+ # Emit subagent.complete with status=cancelled so the
431
+ # TUI's SubagentPanel can mark + reap the row instead
432
+ # of leaving it stuck in "running". Mirrors the
433
+ # complete path below.
434
+ _sched_t("subagent.complete", {
435
+ "task_id": _trace.task_id,
436
+ "status": "cancelled",
437
+ "duration": (_trace.ended_at or 0) - (_trace.started_at or 0),
438
+ "tokens_in": _trace.tokens_in,
439
+ "tokens_out": _trace.tokens_out,
440
+ })
429
441
  return f"[{label} | task_id={task_id_for_output}] Cancelled by user."
430
442
  if isinstance(event, RunOutput):
431
443
  run_output = event
@@ -433,9 +445,23 @@ Do not create documentation files unless explicitly asked.
433
445
  elif isinstance(event, ToolCallStartedEvent):
434
446
  if hasattr(event, "tool") and event.tool:
435
447
  t_id = getattr(event.tool, "tool_call_id", None) or (event.tool.tool_name or "tool")
448
+ t_name_start = event.tool.tool_name or "tool"
449
+ t_args_start = getattr(event.tool, "tool_args", None)
436
450
  else:
437
451
  t_id = getattr(event, "tool_call_id", None) or getattr(event, "tool_name", "tool")
452
+ t_name_start = getattr(event, "tool_name", "tool")
453
+ t_args_start = getattr(event, "tool_args", None)
438
454
  _tool_starts[t_id] = _time.monotonic()
455
+ # Live TUI: tell the SubagentPanel which subagent is
456
+ # currently running which tool, so a fan-out of N
457
+ # delegates renders as N rows each showing its own
458
+ # in-flight tool instead of opaque "running…".
459
+ _sched_t("subagent.tool.started", {
460
+ "task_id": _trace.task_id,
461
+ "tool_id": str(t_id),
462
+ "tool_name": t_name_start,
463
+ "tool_args_preview": (str(t_args_start) if t_args_start else "")[:80],
464
+ })
439
465
  elif isinstance(event, ToolCallCompletedEvent):
440
466
  if hasattr(event, "tool") and event.tool:
441
467
  t_id = getattr(event.tool, "tool_call_id", None) or getattr(event.tool, "tool_name", "tool")
@@ -452,12 +478,29 @@ Do not create documentation files unless explicitly asked.
452
478
  "args_preview": (str(t_args) if t_args else "")[:150],
453
479
  "duration": round(dur, 3),
454
480
  })
481
+ _sched_t("subagent.tool.completed", {
482
+ "task_id": _trace.task_id,
483
+ "tool_id": str(t_id),
484
+ "tool_name": t_name,
485
+ "duration_ms": round(dur * 1000, 1),
486
+ "error": None,
487
+ })
455
488
  elif isinstance(event, RunContentEvent):
456
489
  if hasattr(event, "content") and event.content:
457
490
  result_content += event.content
458
491
  except Exception:
459
492
  _trace.status = "error"
460
493
  _trace.ended_at = _time.monotonic()
494
+ # Same rationale as cancel: surface a complete event with
495
+ # status=error so the panel can stop the row instead of
496
+ # leaving it spinning forever.
497
+ _sched_t("subagent.complete", {
498
+ "task_id": _trace.task_id,
499
+ "status": "error",
500
+ "duration": (_trace.ended_at or 0) - (_trace.started_at or 0),
501
+ "tokens_in": _trace.tokens_in,
502
+ "tokens_out": _trace.tokens_out,
503
+ })
461
504
  raise
462
505
 
463
506
  if run_output and hasattr(run_output, "metrics") and run_output.metrics:
@@ -59,6 +59,11 @@ def _show(panel: Panel) -> None:
59
59
  so we route the panel into the ChatPane via ``call_from_thread``
60
60
  instead (matches how TextualBusSink hands rich renderables off to
61
61
  the app loop).
62
+
63
+ The TUI sidebar (``TasklistPanel``) listens to ``tasklist.updated``
64
+ / ``plan.updated`` events directly — it does not use this path. The
65
+ REPL still gets the panel printed; the TUI also still prints it
66
+ inline as a fallback when the user has the sidebar hidden.
62
67
  """
63
68
  ctx = get_ctx()
64
69
  if ctx.display and hasattr(ctx.display, "show_permission"):
@@ -69,25 +74,106 @@ def _show(panel: Panel) -> None:
69
74
  return
70
75
  tui_app = getattr(ctx, "tui_app", None)
71
76
  if tui_app is not None:
77
+ # Sidebar consumes events; we only print into chat when the
78
+ # sidebar is hidden (Ctrl+B toggle) so the user still has
79
+ # somewhere to see the panel.
72
80
  try:
73
- from aru.tui.widgets.chat import ChatPane
74
- chat = tui_app.query_one(ChatPane)
75
- # Scrollable=True so a big panel (diff preview / task list /
76
- # plan steps) stays contained — the user can scroll inside
77
- # it without losing the chat stream above/below.
78
- kwargs = {"scrollable": True}
81
+ from aru.tui.widgets.tasklist_panel import TasklistPanel
82
+ sidebar = None
79
83
  try:
80
- tui_app.call_from_thread(
81
- chat.add_renderable, panel, **kwargs
82
- )
84
+ sidebar = tui_app.query_one(TasklistPanel)
83
85
  except Exception:
84
- chat.add_renderable(panel, **kwargs)
86
+ pass
87
+ if sidebar is not None and sidebar.has_class("-hidden"):
88
+ from aru.tui.widgets.chat import ChatPane
89
+ chat = tui_app.query_one(ChatPane)
90
+ kwargs = {"scrollable": True}
91
+ try:
92
+ tui_app.call_from_thread(
93
+ chat.add_renderable, panel, **kwargs
94
+ )
95
+ except Exception:
96
+ chat.add_renderable(panel, **kwargs)
97
+ # When the sidebar is visible, the event-driven render is
98
+ # the canonical view; chat stays clean.
85
99
  return
86
100
  except Exception:
87
101
  pass
88
102
  ctx.console.print(panel)
89
103
 
90
104
 
105
+ def _publish_tasklist(tasks: list[dict]) -> None:
106
+ """Best-effort publish of ``tasklist.updated`` to the plugin bus."""
107
+ try:
108
+ ctx = get_ctx()
109
+ mgr = getattr(ctx, "plugin_manager", None)
110
+ if mgr is None:
111
+ return
112
+ payload = {"tasks": [dict(t) for t in tasks]}
113
+ # Plugin manager.publish is async; spawn as a task so call sites
114
+ # that are themselves sync (tools run in threads) don't block.
115
+ try:
116
+ import asyncio
117
+ loop = asyncio.get_running_loop()
118
+ loop.create_task(mgr.publish("tasklist.updated", payload))
119
+ except RuntimeError:
120
+ # No running loop in this thread — fall back to scheduling
121
+ # on the App's loop if we can reach it.
122
+ tui_app = getattr(ctx, "tui_app", None)
123
+ if tui_app is not None:
124
+ try:
125
+ tui_app.call_from_thread(
126
+ _schedule_publish, mgr, "tasklist.updated", payload
127
+ )
128
+ except Exception:
129
+ pass
130
+ except Exception:
131
+ pass
132
+
133
+
134
+ def _publish_plan(steps: list) -> None:
135
+ """Best-effort publish of ``plan.updated`` with the current plan."""
136
+ try:
137
+ ctx = get_ctx()
138
+ mgr = getattr(ctx, "plugin_manager", None)
139
+ if mgr is None:
140
+ return
141
+ payload = {
142
+ "steps": [
143
+ {
144
+ "index": getattr(s, "index", 0),
145
+ "description": getattr(s, "description", ""),
146
+ "status": getattr(s, "status", "pending"),
147
+ }
148
+ for s in steps
149
+ ]
150
+ }
151
+ try:
152
+ import asyncio
153
+ loop = asyncio.get_running_loop()
154
+ loop.create_task(mgr.publish("plan.updated", payload))
155
+ except RuntimeError:
156
+ tui_app = getattr(ctx, "tui_app", None)
157
+ if tui_app is not None:
158
+ try:
159
+ tui_app.call_from_thread(
160
+ _schedule_publish, mgr, "plan.updated", payload
161
+ )
162
+ except Exception:
163
+ pass
164
+ except Exception:
165
+ pass
166
+
167
+
168
+ def _schedule_publish(mgr, event_type: str, payload: dict) -> None:
169
+ """Helper run via call_from_thread to schedule an async publish."""
170
+ try:
171
+ import asyncio
172
+ asyncio.create_task(mgr.publish(event_type, payload))
173
+ except Exception:
174
+ pass
175
+
176
+
91
177
  def create_task_list(tasks: list[str]) -> str:
92
178
  """Set (or replace) the subtask list for the current phase.
93
179
 
@@ -115,6 +201,7 @@ def create_task_list(tasks: list[str]) -> str:
115
201
  created = store.create(tasks)
116
202
  panel = _render_task_list(created)
117
203
  _show(panel)
204
+ _publish_tasklist(created)
118
205
 
119
206
  task_lines = "\n".join(f" {t['index']}. {t['description']}" for t in created)
120
207
  verb = "replaced" if was_replaced else "created"
@@ -143,6 +230,7 @@ def update_task(index: int, status: str) -> str:
143
230
  all_tasks = store.get_all()
144
231
  panel = _render_task_list(all_tasks)
145
232
  _show(panel)
233
+ _publish_tasklist(all_tasks)
146
234
 
147
235
  # Check if all done
148
236
  completed_count = sum(1 for t in all_tasks if t["status"] == "completed")
@@ -177,6 +265,7 @@ def flush_plan_render(session) -> None:
177
265
  if not steps:
178
266
  return
179
267
  _show(_render_plan_steps(steps))
268
+ _publish_plan(steps)
180
269
 
181
270
 
182
271
  def _render_plan_steps(steps: list) -> Panel: