ccgram 2.2.0__tar.gz → 2.2.2__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 (188) hide show
  1. {ccgram-2.2.0 → ccgram-2.2.2}/CHANGELOG.md +15 -0
  2. {ccgram-2.2.0 → ccgram-2.2.2}/PKG-INFO +1 -1
  3. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/_version.py +2 -2
  4. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/bot.py +4 -1
  5. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/cc_commands.py +37 -6
  6. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/hook_events.py +66 -23
  7. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/message_queue.py +25 -8
  8. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/status_polling.py +6 -5
  9. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_hook_events.py +226 -24
  10. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_status_polling.py +49 -2
  11. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_tool_batching.py +38 -0
  12. {ccgram-2.2.0 → ccgram-2.2.2}/.claude/rules/architecture.md +0 -0
  13. {ccgram-2.2.0 → ccgram-2.2.2}/.claude/rules/message-handling.md +0 -0
  14. {ccgram-2.2.0 → ccgram-2.2.2}/.claude/rules/topic-architecture.md +0 -0
  15. {ccgram-2.2.0 → ccgram-2.2.2}/.claude/skills/releasing/SKILL.md +0 -0
  16. {ccgram-2.2.0 → ccgram-2.2.2}/.env.example +0 -0
  17. {ccgram-2.2.0 → ccgram-2.2.2}/.github/workflows/ci.yml +0 -0
  18. {ccgram-2.2.0 → ccgram-2.2.2}/.github/workflows/release.yml +0 -0
  19. {ccgram-2.2.0 → ccgram-2.2.2}/.gitignore +0 -0
  20. {ccgram-2.2.0 → ccgram-2.2.2}/CLAUDE.md +0 -0
  21. {ccgram-2.2.0 → ccgram-2.2.2}/LICENSE +0 -0
  22. {ccgram-2.2.0 → ccgram-2.2.2}/Makefile +0 -0
  23. {ccgram-2.2.0 → ccgram-2.2.2}/README.md +0 -0
  24. {ccgram-2.2.0 → ccgram-2.2.2}/cliff.toml +0 -0
  25. {ccgram-2.2.0 → ccgram-2.2.2}/docs/ai-agents/README.md +0 -0
  26. {ccgram-2.2.0 → ccgram-2.2.2}/docs/ai-agents/architecture-map.md +0 -0
  27. {ccgram-2.2.0 → ccgram-2.2.2}/docs/ai-agents/codebase-index.md +0 -0
  28. {ccgram-2.2.0 → ccgram-2.2.2}/docs/ai-agents/extension-and-fix-playbook.md +0 -0
  29. {ccgram-2.2.0 → ccgram-2.2.2}/docs/ai-agents/tooling-and-tests.md +0 -0
  30. {ccgram-2.2.0 → ccgram-2.2.2}/docs/guides.md +0 -0
  31. {ccgram-2.2.0 → ccgram-2.2.2}/llm.txt +0 -0
  32. {ccgram-2.2.0 → ccgram-2.2.2}/pyproject.toml +0 -0
  33. {ccgram-2.2.0 → ccgram-2.2.2}/scripts/generate_homebrew_formula.py +0 -0
  34. {ccgram-2.2.0 → ccgram-2.2.2}/scripts/restart.sh +0 -0
  35. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/__init__.py +0 -0
  36. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/cli.py +0 -0
  37. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/codex_status.py +0 -0
  38. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/command_catalog.py +0 -0
  39. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/config.py +0 -0
  40. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/doctor_cmd.py +0 -0
  41. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/fonts/JetBrainsMono-Regular.ttf +0 -0
  42. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/fonts/LICENSE-JetBrainsMono.txt +0 -0
  43. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/fonts/LICENSE-NotoSansMono.txt +0 -0
  44. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/fonts/LICENSE-Symbola.txt +0 -0
  45. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/fonts/NotoSansMonoCJKsc-Regular.otf +0 -0
  46. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/fonts/Symbola.ttf +0 -0
  47. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/__init__.py +0 -0
  48. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/callback_data.py +0 -0
  49. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/callback_helpers.py +0 -0
  50. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/cleanup.py +0 -0
  51. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/command_history.py +0 -0
  52. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/directory_browser.py +0 -0
  53. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/directory_callbacks.py +0 -0
  54. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/file_handler.py +0 -0
  55. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/history.py +0 -0
  56. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/history_callbacks.py +0 -0
  57. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/interactive_callbacks.py +0 -0
  58. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/interactive_ui.py +0 -0
  59. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/message_sender.py +0 -0
  60. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/recovery_callbacks.py +0 -0
  61. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/response_builder.py +0 -0
  62. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/restore_command.py +0 -0
  63. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/resume_command.py +0 -0
  64. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/screenshot_callbacks.py +0 -0
  65. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/sessions_dashboard.py +0 -0
  66. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/sync_command.py +0 -0
  67. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/text_handler.py +0 -0
  68. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/topic_emoji.py +0 -0
  69. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/upgrade.py +0 -0
  70. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/user_state.py +0 -0
  71. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/voice_callbacks.py +0 -0
  72. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/voice_handler.py +0 -0
  73. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/handlers/window_callbacks.py +0 -0
  74. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/hook.py +0 -0
  75. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/interactive_prompt_formatter.py +0 -0
  76. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/main.py +0 -0
  77. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/markdown_v2.py +0 -0
  78. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/monitor_state.py +0 -0
  79. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/providers/__init__.py +0 -0
  80. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/providers/_jsonl.py +0 -0
  81. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/providers/base.py +0 -0
  82. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/providers/claude.py +0 -0
  83. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/providers/codex.py +0 -0
  84. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/providers/gemini.py +0 -0
  85. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/providers/registry.py +0 -0
  86. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/screen_buffer.py +0 -0
  87. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/screenshot.py +0 -0
  88. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/session.py +0 -0
  89. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/session_monitor.py +0 -0
  90. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/state_persistence.py +0 -0
  91. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/status_cmd.py +0 -0
  92. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/telegram_request.py +0 -0
  93. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/telegram_sender.py +0 -0
  94. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/terminal_parser.py +0 -0
  95. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/tmux_manager.py +0 -0
  96. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/transcript_parser.py +0 -0
  97. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/utils.py +0 -0
  98. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/whisper/__init__.py +0 -0
  99. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/whisper/base.py +0 -0
  100. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/whisper/httpx_transcriber.py +0 -0
  101. {ccgram-2.2.0 → ccgram-2.2.2}/src/ccgram/window_resolver.py +0 -0
  102. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/conftest.py +0 -0
  103. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/handlers/__init__.py +0 -0
  104. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/handlers/test_command_history.py +0 -0
  105. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/handlers/test_history.py +0 -0
  106. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/handlers/test_response_builder.py +0 -0
  107. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/handlers/test_voice_handler.py +0 -0
  108. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_bot_callbacks.py +0 -0
  109. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_callback_auth.py +0 -0
  110. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_cc_commands.py +0 -0
  111. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_claude_characterization.py +0 -0
  112. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_cleanup.py +0 -0
  113. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_cli.py +0 -0
  114. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_codex_status.py +0 -0
  115. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_command_catalog.py +0 -0
  116. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_commands_command.py +0 -0
  117. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_config.py +0 -0
  118. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_directory_browser.py +0 -0
  119. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_doctor_cmd.py +0 -0
  120. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_emdash_integration.py +0 -0
  121. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_external_discovery.py +0 -0
  122. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_file_handler.py +0 -0
  123. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_forward_command.py +0 -0
  124. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_group_filter.py +0 -0
  125. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_handle_new_window.py +0 -0
  126. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_hook.py +0 -0
  127. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_interactive_prompt_formatter.py +0 -0
  128. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_interactive_ui.py +0 -0
  129. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_jsonl_providers.py +0 -0
  130. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_kill_command.py +0 -0
  131. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_markdown_v2.py +0 -0
  132. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_message_queue_properties.py +0 -0
  133. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_message_sender.py +0 -0
  134. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_monitor_state.py +0 -0
  135. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_new_command.py +0 -0
  136. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_new_window_sync.py +0 -0
  137. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_provider_autodetect.py +0 -0
  138. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_provider_contracts.py +0 -0
  139. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_provider_registry.py +0 -0
  140. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_provider_selection.py +0 -0
  141. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_recovery_ui.py +0 -0
  142. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_restore_command.py +0 -0
  143. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_resume_command.py +0 -0
  144. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_screen_buffer.py +0 -0
  145. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_session.py +0 -0
  146. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_session_favorites.py +0 -0
  147. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_session_monitor.py +0 -0
  148. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_session_monitor_events.py +0 -0
  149. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_session_notification_mode.py +0 -0
  150. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_sessions_dashboard.py +0 -0
  151. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_state_migration.py +0 -0
  152. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_status_buttons.py +0 -0
  153. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_status_cmd.py +0 -0
  154. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_status_recall_callback.py +0 -0
  155. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_sync_command.py +0 -0
  156. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_task_utils.py +0 -0
  157. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_telegram_request.py +0 -0
  158. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_telegram_sender.py +0 -0
  159. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_terminal_parser.py +0 -0
  160. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_text_handler.py +0 -0
  161. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_tmux_autodetect.py +0 -0
  162. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_topic_edited.py +0 -0
  163. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_topic_emoji.py +0 -0
  164. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_transcript_parser.py +0 -0
  165. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_utils.py +0 -0
  166. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_vim_mode.py +0 -0
  167. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/test_window_callbacks.py +0 -0
  168. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/whisper/__init__.py +0 -0
  169. {ccgram-2.2.0 → ccgram-2.2.2}/tests/ccgram/whisper/test_transcriber.py +0 -0
  170. {ccgram-2.2.0 → ccgram-2.2.2}/tests/conftest.py +0 -0
  171. {ccgram-2.2.0 → ccgram-2.2.2}/tests/e2e/__init__.py +0 -0
  172. {ccgram-2.2.0 → ccgram-2.2.2}/tests/e2e/_helpers.py +0 -0
  173. {ccgram-2.2.0 → ccgram-2.2.2}/tests/e2e/conftest.py +0 -0
  174. {ccgram-2.2.0 → ccgram-2.2.2}/tests/e2e/test_claude_lifecycle.py +0 -0
  175. {ccgram-2.2.0 → ccgram-2.2.2}/tests/e2e/test_codex_lifecycle.py +0 -0
  176. {ccgram-2.2.0 → ccgram-2.2.2}/tests/e2e/test_gemini_lifecycle.py +0 -0
  177. {ccgram-2.2.0 → ccgram-2.2.2}/tests/e2e/test_voice_lifecycle.py +0 -0
  178. {ccgram-2.2.0 → ccgram-2.2.2}/tests/integration/conftest.py +0 -0
  179. {ccgram-2.2.0 → ccgram-2.2.2}/tests/integration/test_autodetect_integration.py +0 -0
  180. {ccgram-2.2.0 → ccgram-2.2.2}/tests/integration/test_config_integration.py +0 -0
  181. {ccgram-2.2.0 → ccgram-2.2.2}/tests/integration/test_hook_pipeline.py +0 -0
  182. {ccgram-2.2.0 → ccgram-2.2.2}/tests/integration/test_message_dispatch.py +0 -0
  183. {ccgram-2.2.0 → ccgram-2.2.2}/tests/integration/test_monitor_flow.py +0 -0
  184. {ccgram-2.2.0 → ccgram-2.2.2}/tests/integration/test_monitor_state_integration.py +0 -0
  185. {ccgram-2.2.0 → ccgram-2.2.2}/tests/integration/test_state_roundtrip.py +0 -0
  186. {ccgram-2.2.0 → ccgram-2.2.2}/tests/integration/test_tmux_manager.py +0 -0
  187. {ccgram-2.2.0 → ccgram-2.2.2}/tests/integration/test_whisper_integration.py +0 -0
  188. {ccgram-2.2.0 → ccgram-2.2.2}/uv.lock +0 -0
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ ## [2.2.2] - 2026-03-20
8
+
9
+ ### Fixed
10
+ - Handle Telegram flood control during startup command registration
11
+
12
+ ## [2.2.1] - 2026-03-20
13
+
14
+ ### Added
15
+ - Subagent context binding ([#32](https://github.com/alexei-led/ccgram/pull/32))
16
+
17
+
18
+ ### Documentation
19
+ - Update CHANGELOG.md for v2.2.1
20
+
7
21
  ## [2.2.0] - 2026-03-20
8
22
 
9
23
  ### Added
@@ -12,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
12
26
 
13
27
  ### Documentation
14
28
  - Update release process in CLAUDE.md [skip ci]
29
+ - Update CHANGELOG.md for v2.2.0
15
30
 
16
31
  ## [2.1.2] - 2026-03-20
17
32
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccgram
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: CCGram — manage AI coding agents from Telegram via tmux
5
5
  Project-URL: Homepage, https://github.com/alexei-led/ccgram
6
6
  Project-URL: Repository, https://github.com/alexei-led/ccgram
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '2.2.0'
32
- __version_tuple__ = version_tuple = (2, 2, 0)
31
+ __version__ = version = '2.2.2'
32
+ __version_tuple__ = version_tuple = (2, 2, 2)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -1649,7 +1649,10 @@ async def post_init(application: Application) -> None:
1649
1649
  global session_monitor, _status_poll_task, _global_provider_menu
1650
1650
 
1651
1651
  default_provider = get_provider()
1652
- await register_commands(application.bot, provider=default_provider)
1652
+ try:
1653
+ await register_commands(application.bot, provider=default_provider)
1654
+ except TelegramError:
1655
+ logger.warning("Failed to register bot commands at startup, will retry later")
1653
1656
  _global_provider_menu = default_provider.capabilities.name
1654
1657
 
1655
1658
  # Refresh bot command menu every 10 minutes.
@@ -286,10 +286,41 @@ async def register_commands(
286
286
  bot_commands.append(BotCommand(cmd.telegram_name, desc))
287
287
  cc_count += 1
288
288
 
289
- if scope is None:
290
- await bot.delete_my_commands()
291
- await bot.set_my_commands(bot_commands)
292
- else:
293
- await bot.delete_my_commands(scope=scope)
294
- await bot.set_my_commands(bot_commands, scope=scope)
289
+ import asyncio
290
+
291
+ from telegram.error import RetryAfter
292
+
293
+ max_attempts = 3
294
+ for attempt in range(1, max_attempts + 1):
295
+ try:
296
+ if scope is None:
297
+ await bot.delete_my_commands()
298
+ await bot.set_my_commands(bot_commands)
299
+ else:
300
+ await bot.delete_my_commands(scope=scope)
301
+ await bot.set_my_commands(bot_commands, scope=scope)
302
+ break
303
+ except RetryAfter as e:
304
+ retry_secs = min(
305
+ 60,
306
+ (
307
+ e.retry_after
308
+ if isinstance(e.retry_after, int)
309
+ else int(e.retry_after.total_seconds())
310
+ ),
311
+ )
312
+ if attempt < max_attempts:
313
+ logger.warning(
314
+ "Telegram flood control registering commands, retry in %ds (attempt %d/%d)",
315
+ retry_secs,
316
+ attempt,
317
+ max_attempts,
318
+ )
319
+ await asyncio.sleep(retry_secs)
320
+ else:
321
+ logger.warning(
322
+ "Telegram flood control registering commands, giving up after %d attempts",
323
+ max_attempts,
324
+ )
325
+ return
295
326
  logger.info("Registered %d bot commands (%d CC)", len(bot_commands), cc_count)
@@ -133,13 +133,28 @@ async def _handle_stop(event: HookEvent, bot: Bot) -> None:
133
133
  await enqueue_status_update(bot, user_id, window_id, None, thread_id=thread_id)
134
134
 
135
135
 
136
- # Track active subagents per window: window_id -> set of subagent_ids
137
- _active_subagents: dict[str, set[str]] = {}
136
+ # Track active subagents per window: window_id -> {subagent_id -> name}
137
+ _active_subagents: dict[str, dict[str, str]] = {}
138
138
 
139
+ _MAX_DISPLAYED_NAMES = 3
139
140
 
140
- def get_subagent_count(window_id: str) -> int:
141
- """Return the number of active subagents for a window."""
142
- return len(_active_subagents.get(window_id, set()))
141
+
142
+ def get_subagent_names(window_id: str) -> list[str]:
143
+ """Return names of active subagents for a window."""
144
+ return list(_active_subagents.get(window_id, {}).values())
145
+
146
+
147
+ def build_subagent_label(names: list[str]) -> str | None:
148
+ """Build a display label for active subagents.
149
+
150
+ Returns None if no subagents are active.
151
+ """
152
+ if not names:
153
+ return None
154
+ if len(names) == 1:
155
+ return f"\U0001f916 {names[0]}"
156
+ joined = ", ".join(names[:_MAX_DISPLAYED_NAMES])
157
+ return f"\U0001f916 {len(names)} subagents: {joined}"
143
158
 
144
159
 
145
160
  def clear_subagents(window_id: str) -> None:
@@ -147,28 +162,46 @@ def clear_subagents(window_id: str) -> None:
147
162
  _active_subagents.pop(window_id, None)
148
163
 
149
164
 
150
- async def _handle_subagent_start(event: HookEvent, bot: Bot) -> None: # noqa: ARG001
151
- """Handle SubagentStart — track active subagent."""
165
+ async def _handle_subagent_start(event: HookEvent, bot: Bot) -> None:
166
+ """Handle SubagentStart — track active subagent and notify."""
167
+ from .message_queue import enqueue_status_update
168
+
152
169
  users = _resolve_users_for_window_key(event.window_key)
153
170
  if not users:
154
171
  return
155
172
 
156
173
  window_id = users[0][2] # all users share the same window_id
157
174
  subagent_id = event.data.get("subagent_id", "")
175
+ name = (
176
+ (event.data.get("name") or "").strip()
177
+ or (event.data.get("description") or "").strip()
178
+ or subagent_id[:12]
179
+ or "subagent"
180
+ )
158
181
 
159
- _active_subagents.setdefault(window_id, set()).add(subagent_id)
182
+ _active_subagents.setdefault(window_id, {})[subagent_id] = name
160
183
 
161
- count = len(_active_subagents[window_id])
162
184
  logger.debug(
163
185
  "Subagent started: window=%s, count=%d, name=%s",
164
186
  window_id,
165
- count,
166
- event.data.get("name", ""),
187
+ len(_active_subagents[window_id]),
188
+ name,
167
189
  )
168
190
 
191
+ for user_id, thread_id, _ in users:
192
+ await enqueue_status_update(
193
+ bot,
194
+ user_id,
195
+ window_id,
196
+ f"\U0001f916 Subagent started: {name}",
197
+ thread_id=thread_id,
198
+ )
199
+
200
+
201
+ async def _handle_subagent_stop(event: HookEvent, bot: Bot) -> None:
202
+ """Handle SubagentStop — remove subagent from tracking and notify."""
203
+ from .message_queue import enqueue_status_update
169
204
 
170
- async def _handle_subagent_stop(event: HookEvent, bot: Bot) -> None: # noqa: ARG001
171
- """Handle SubagentStop — remove subagent from tracking."""
172
205
  users = _resolve_users_for_window_key(event.window_key)
173
206
  if not users:
174
207
  return
@@ -176,21 +209,29 @@ async def _handle_subagent_stop(event: HookEvent, bot: Bot) -> None: # noqa: AR
176
209
  window_id = users[0][2]
177
210
  subagent_id = event.data.get("subagent_id", "")
178
211
 
179
- ids = _active_subagents.get(window_id)
180
- if not ids:
212
+ agents = _active_subagents.get(window_id)
213
+ if not agents:
181
214
  return
182
- ids.discard(subagent_id)
183
- if not ids:
215
+ name = agents.pop(subagent_id, subagent_id[:12] or "subagent")
216
+ if not agents:
184
217
  _active_subagents.pop(window_id, None)
185
218
 
186
- count = get_subagent_count(window_id)
187
219
  logger.debug(
188
- "Subagent stopped: window=%s, remaining=%d, id=%s",
220
+ "Subagent stopped: window=%s, remaining=%d, name=%s",
189
221
  window_id,
190
- count,
191
- subagent_id,
222
+ len(_active_subagents.get(window_id, {})),
223
+ name,
192
224
  )
193
225
 
226
+ for user_id, thread_id, _ in users:
227
+ await enqueue_status_update(
228
+ bot,
229
+ user_id,
230
+ window_id,
231
+ f"\U0001f916 Subagent done: {name}",
232
+ thread_id=thread_id,
233
+ )
234
+
194
235
 
195
236
  async def _handle_teammate_idle(event: HookEvent, bot: Bot) -> None:
196
237
  """Handle TeammateIdle — notify topic that a teammate went idle."""
@@ -254,9 +295,11 @@ async def _handle_session_end(event: HookEvent, bot: Bot) -> None:
254
295
  reason,
255
296
  )
256
297
 
257
- # Clear session association so next launch gets a fresh session
298
+ # Clear session association and subagent tracking so next launch starts fresh
258
299
  if users:
259
- session_manager.clear_window_session(users[0][2])
300
+ window_id = users[0][2]
301
+ session_manager.clear_window_session(window_id)
302
+ clear_subagents(window_id)
260
303
 
261
304
  for user_id, thread_id, window_id in users:
262
305
  clear_seen_status(window_id)
@@ -83,18 +83,23 @@ def _is_batch_eligible(task: MessageTask) -> bool:
83
83
  )
84
84
 
85
85
 
86
- def format_batch_message(entries: list[ToolBatchEntry]) -> str:
86
+ def format_batch_message(
87
+ entries: list[ToolBatchEntry], subagent_label: str | None = None
88
+ ) -> str:
87
89
  """Render a batch of tool calls as a single compact message.
88
90
 
89
91
  Format:
90
- ⚡ 3 tool calls
92
+ ⚡ 3 tool calls [🤖 write-tests]
91
93
  📖 Read src/foo.py ⎿ 42 lines
92
94
  ✏️ Edit src/foo.py ⎿ +3 −1
93
95
  ⚡ Bash make test ⏳
94
96
  """
95
97
  count = len(entries)
96
98
  label = "tool call" if count == 1 else "tool calls"
97
- lines = [f"\u26a1 {count} {label}"]
99
+ header = f"\u26a1 {count} {label}"
100
+ if subagent_label:
101
+ header = f"{header} [{subagent_label}]"
102
+ lines = [header]
98
103
 
99
104
  for entry in entries:
100
105
  line = entry.tool_use_text
@@ -400,7 +405,10 @@ async def _process_batch_task(bot: Bot, user_id: int, task: MessageTask) -> None
400
405
  return
401
406
 
402
407
  # Send or edit batch message
403
- batch_text = format_batch_message(batch.entries)
408
+ from .hook_events import build_subagent_label, get_subagent_names
409
+
410
+ subagent_label = build_subagent_label(get_subagent_names(window_id))
411
+ batch_text = format_batch_message(batch.entries, subagent_label=subagent_label)
404
412
 
405
413
  if batch.telegram_msg_id is None:
406
414
  # Clear status message first, then send new batch message
@@ -445,7 +453,11 @@ async def _flush_batch(bot: Bot, user_id: int, thread_id_or_0: int) -> None:
445
453
 
446
454
  thread_id: int | None = thread_id_or_0 if thread_id_or_0 != 0 else None
447
455
  chat_id = session_manager.resolve_chat_id(user_id, thread_id)
448
- batch_text = format_batch_message(batch.entries)
456
+
457
+ from .hook_events import build_subagent_label, get_subagent_names
458
+
459
+ subagent_label = build_subagent_label(get_subagent_names(batch.window_id))
460
+ batch_text = format_batch_message(batch.entries, subagent_label=subagent_label)
449
461
 
450
462
  if batch.telegram_msg_id is None:
451
463
  # First send failed earlier — attempt one send before dropping
@@ -878,9 +890,14 @@ async def _check_and_send_status(
878
890
 
879
891
  status = get_provider_for_window(window_id).parse_terminal_status(pane_text)
880
892
  if status and not status.is_interactive:
881
- await _do_send_status_message(
882
- bot, user_id, thread_id_or_0, window_id, status.display_label
883
- )
893
+ from .hook_events import build_subagent_label, get_subagent_names
894
+
895
+ display = status.display_label
896
+ subagent_names = get_subagent_names(window_id)
897
+ if subagent_names:
898
+ label = build_subagent_label(subagent_names)
899
+ display = f"{display} ({label})"
900
+ await _do_send_status_message(bot, user_id, thread_id_or_0, window_id, display)
884
901
 
885
902
 
886
903
  async def enqueue_content_message(
@@ -824,13 +824,14 @@ async def update_status_message(
824
824
  ws.startup_time = None
825
825
  await _send_typing_throttled(bot, user_id, thread_id)
826
826
  if notif_mode not in ("muted", "errors_only"):
827
- # Append subagent count if any are active
828
- from .hook_events import get_subagent_count
827
+ # Append subagent names if any are active
828
+ from .hook_events import build_subagent_label, get_subagent_names
829
829
 
830
- subagent_count = get_subagent_count(window_id)
830
+ subagent_names = get_subagent_names(window_id)
831
831
  display_status = status_line
832
- if subagent_count:
833
- display_status = f"{status_line} ({subagent_count} subagent{'s' if subagent_count > 1 else ''})"
832
+ if subagent_names:
833
+ label = build_subagent_label(subagent_names)
834
+ display_status = f"{status_line} ({label})"
834
835
  await enqueue_status_update(
835
836
  bot,
836
837
  user_id,
@@ -8,9 +8,10 @@ from ccgram.handlers.hook_events import (
8
8
  HookEvent,
9
9
  _active_subagents,
10
10
  _resolve_users_for_window_key,
11
+ build_subagent_label,
11
12
  clear_subagents,
12
13
  dispatch_hook_event,
13
- get_subagent_count,
14
+ get_subagent_names,
14
15
  )
15
16
 
16
17
 
@@ -64,17 +65,55 @@ class TestSubagentTracking:
64
65
  def setup_method(self) -> None:
65
66
  _active_subagents.clear()
66
67
 
67
- def test_start_increments_count(self) -> None:
68
- _active_subagents["@0"] = {"a1"}
69
- assert get_subagent_count("@0") == 1
68
+ def test_count_via_names(self) -> None:
69
+ _active_subagents["@0"] = {"a1": "agent-1"}
70
+ assert len(get_subagent_names("@0")) == 1
70
71
 
71
72
  def test_clear_removes_all(self) -> None:
72
- _active_subagents["@0"] = {"a1", "a2"}
73
+ _active_subagents["@0"] = {"a1": "agent-1", "a2": "agent-2"}
73
74
  clear_subagents("@0")
74
- assert get_subagent_count("@0") == 0
75
+ assert get_subagent_names("@0") == []
75
76
 
76
- def test_count_missing_window(self) -> None:
77
- assert get_subagent_count("@999") == 0
77
+ def test_names_missing_window(self) -> None:
78
+ assert get_subagent_names("@999") == []
79
+
80
+ def test_get_names_returns_values(self) -> None:
81
+ _active_subagents["@0"] = {"a1": "write-tests", "a2": "refactor"}
82
+ names = get_subagent_names("@0")
83
+ assert sorted(names) == ["refactor", "write-tests"]
84
+
85
+ def test_get_names_empty_after_clear(self) -> None:
86
+ _active_subagents["@0"] = {"a1": "agent-1"}
87
+ clear_subagents("@0")
88
+ assert get_subagent_names("@0") == []
89
+
90
+
91
+ class TestBuildSubagentLabel:
92
+ def test_empty_list(self) -> None:
93
+ assert build_subagent_label([]) is None
94
+
95
+ def test_single_name(self) -> None:
96
+ assert build_subagent_label(["write-tests"]) == "\U0001f916 write-tests"
97
+
98
+ def test_multiple_names(self) -> None:
99
+ result = build_subagent_label(["write-tests", "refactor"])
100
+ assert result is not None
101
+ assert "\U0001f916" in result
102
+ assert "2 subagents" in result
103
+ assert "write-tests" in result
104
+ assert "refactor" in result
105
+
106
+ def test_three_names(self) -> None:
107
+ result = build_subagent_label(["a", "b", "c"])
108
+ assert result is not None
109
+ assert "3 subagents" in result
110
+
111
+ def test_truncates_at_three(self) -> None:
112
+ result = build_subagent_label(["a", "b", "c", "d"])
113
+ assert result is not None
114
+ assert "4 subagents" in result
115
+ assert "a, b, c" in result
116
+ assert "d" not in result
78
117
 
79
118
 
80
119
  class TestDispatchHookEvent:
@@ -219,25 +258,132 @@ class TestHandleSubagentStart:
219
258
  lambda: iter([(100, 42, "@0")]),
220
259
  )
221
260
  bot = AsyncMock(spec=Bot)
261
+ with patch(
262
+ "ccgram.handlers.message_queue.enqueue_status_update"
263
+ ) as mock_enqueue:
264
+ event = _make_event(
265
+ event_type="SubagentStart",
266
+ data={"subagent_id": "sub-1", "name": "researcher"},
267
+ )
268
+ await dispatch_hook_event(event, bot)
269
+ assert len(get_subagent_names("@0")) == 1
270
+ assert get_subagent_names("@0") == ["researcher"]
271
+ mock_enqueue.assert_called_once_with(
272
+ bot, 100, "@0", "\U0001f916 Subagent started: researcher", thread_id=42
273
+ )
274
+
275
+ async def test_tracks_multiple_subagents(self, monkeypatch) -> None:
276
+ monkeypatch.setattr(
277
+ "ccgram.handlers.hook_events.session_manager.iter_thread_bindings",
278
+ lambda: iter([(100, 42, "@0")]),
279
+ )
280
+ bot = AsyncMock(spec=Bot)
281
+ with patch(
282
+ "ccgram.handlers.message_queue.enqueue_status_update"
283
+ ) as mock_enqueue:
284
+ for sub_id in ("sub-1", "sub-2"):
285
+ event = _make_event(
286
+ event_type="SubagentStart", data={"subagent_id": sub_id}
287
+ )
288
+ await dispatch_hook_event(event, bot)
289
+ assert len(get_subagent_names("@0")) == 2
290
+ assert mock_enqueue.call_count == 2
291
+
292
+ async def test_name_fallback_to_description(self, monkeypatch) -> None:
293
+ monkeypatch.setattr(
294
+ "ccgram.handlers.hook_events.session_manager.iter_thread_bindings",
295
+ lambda: iter([(100, 42, "@0")]),
296
+ )
297
+ bot = AsyncMock(spec=Bot)
298
+ with patch(
299
+ "ccgram.handlers.message_queue.enqueue_status_update"
300
+ ) as mock_enqueue:
301
+ event = _make_event(
302
+ event_type="SubagentStart",
303
+ data={"subagent_id": "sub-1", "description": "explore code"},
304
+ )
305
+ await dispatch_hook_event(event, bot)
306
+ assert get_subagent_names("@0") == ["explore code"]
307
+ assert "explore code" in mock_enqueue.call_args[0][3]
308
+
309
+ async def test_name_fallback_to_truncated_id(self, monkeypatch) -> None:
310
+ monkeypatch.setattr(
311
+ "ccgram.handlers.hook_events.session_manager.iter_thread_bindings",
312
+ lambda: iter([(100, 42, "@0")]),
313
+ )
314
+ bot = AsyncMock(spec=Bot)
315
+ with patch(
316
+ "ccgram.handlers.message_queue.enqueue_status_update"
317
+ ) as mock_enqueue:
318
+ event = _make_event(
319
+ event_type="SubagentStart",
320
+ data={"subagent_id": "abcdef123456789"},
321
+ )
322
+ await dispatch_hook_event(event, bot)
323
+ assert get_subagent_names("@0") == ["abcdef123456"]
324
+ assert "abcdef123456" in mock_enqueue.call_args[0][3]
325
+
326
+ async def test_whitespace_name_falls_back(self, monkeypatch) -> None:
327
+ monkeypatch.setattr(
328
+ "ccgram.handlers.hook_events.session_manager.iter_thread_bindings",
329
+ lambda: iter([(100, 42, "@0")]),
330
+ )
331
+ bot = AsyncMock(spec=Bot)
332
+ with patch("ccgram.handlers.message_queue.enqueue_status_update"):
333
+ event = _make_event(
334
+ event_type="SubagentStart",
335
+ data={"subagent_id": "sub-1", "name": " ", "description": "real"},
336
+ )
337
+ await dispatch_hook_event(event, bot)
338
+ assert get_subagent_names("@0") == ["real"]
339
+
340
+ async def test_empty_everything_uses_fallback(self, monkeypatch) -> None:
341
+ monkeypatch.setattr(
342
+ "ccgram.handlers.hook_events.session_manager.iter_thread_bindings",
343
+ lambda: iter([(100, 42, "@0")]),
344
+ )
345
+ bot = AsyncMock(spec=Bot)
346
+ with patch("ccgram.handlers.message_queue.enqueue_status_update"):
347
+ event = _make_event(
348
+ event_type="SubagentStart",
349
+ data={"subagent_id": "", "name": "", "description": ""},
350
+ )
351
+ await dispatch_hook_event(event, bot)
352
+ assert get_subagent_names("@0") == ["subagent"]
353
+
354
+ async def test_no_users_does_not_track(self, monkeypatch) -> None:
355
+ monkeypatch.setattr(
356
+ "ccgram.handlers.hook_events.session_manager.iter_thread_bindings",
357
+ lambda: iter([]),
358
+ )
359
+ bot = AsyncMock(spec=Bot)
222
360
  event = _make_event(
223
361
  event_type="SubagentStart",
224
- data={"subagent_id": "sub-1", "name": "researcher"},
362
+ data={"subagent_id": "sub-1", "name": "test"},
225
363
  )
226
364
  await dispatch_hook_event(event, bot)
227
- assert get_subagent_count("@0") == 1
365
+ assert _active_subagents == {}
228
366
 
229
- async def test_tracks_multiple_subagents(self, monkeypatch) -> None:
367
+ async def test_notifies_multiple_users(self, monkeypatch) -> None:
230
368
  monkeypatch.setattr(
231
369
  "ccgram.handlers.hook_events.session_manager.iter_thread_bindings",
232
- lambda: iter([(100, 42, "@0")]),
370
+ lambda: iter([(100, 42, "@0"), (200, 99, "@0")]),
233
371
  )
234
372
  bot = AsyncMock(spec=Bot)
235
- for sub_id in ("sub-1", "sub-2"):
373
+ with patch(
374
+ "ccgram.handlers.message_queue.enqueue_status_update"
375
+ ) as mock_enqueue:
236
376
  event = _make_event(
237
- event_type="SubagentStart", data={"subagent_id": sub_id}
377
+ event_type="SubagentStart",
378
+ data={"subagent_id": "sub-1", "name": "researcher"},
238
379
  )
239
380
  await dispatch_hook_event(event, bot)
240
- assert get_subagent_count("@0") == 2
381
+ assert mock_enqueue.call_count == 2
382
+ calls = mock_enqueue.call_args_list
383
+ assert calls[0][0][1] == 100 # first user_id
384
+ assert calls[1][0][1] == 200 # second user_id
385
+ assert calls[0][0][2] == "@0" # window_id from outer scope
386
+ assert calls[1][0][2] == "@0"
241
387
 
242
388
 
243
389
  class TestHandleSubagentStop:
@@ -249,23 +395,49 @@ class TestHandleSubagentStop:
249
395
  "ccgram.handlers.hook_events.session_manager.iter_thread_bindings",
250
396
  lambda: iter([(100, 42, "@0")]),
251
397
  )
252
- _active_subagents["@0"] = {"sub-1", "sub-2"}
398
+ _active_subagents["@0"] = {"sub-1": "agent-1", "sub-2": "agent-2"}
253
399
  bot = AsyncMock(spec=Bot)
254
- event = _make_event(event_type="SubagentStop", data={"subagent_id": "sub-1"})
255
- await dispatch_hook_event(event, bot)
256
- assert get_subagent_count("@0") == 1
400
+ with patch(
401
+ "ccgram.handlers.message_queue.enqueue_status_update"
402
+ ) as mock_enqueue:
403
+ event = _make_event(
404
+ event_type="SubagentStop", data={"subagent_id": "sub-1"}
405
+ )
406
+ await dispatch_hook_event(event, bot)
407
+ assert len(get_subagent_names("@0")) == 1
408
+ mock_enqueue.assert_called_once_with(
409
+ bot, 100, "@0", "\U0001f916 Subagent done: agent-1", thread_id=42
410
+ )
257
411
 
258
412
  async def test_removes_last_subagent_cleans_dict(self, monkeypatch) -> None:
259
413
  monkeypatch.setattr(
260
414
  "ccgram.handlers.hook_events.session_manager.iter_thread_bindings",
261
415
  lambda: iter([(100, 42, "@0")]),
262
416
  )
263
- _active_subagents["@0"] = {"sub-1"}
417
+ _active_subagents["@0"] = {"sub-1": "agent-1"}
264
418
  bot = AsyncMock(spec=Bot)
265
- event = _make_event(event_type="SubagentStop", data={"subagent_id": "sub-1"})
266
- await dispatch_hook_event(event, bot)
267
- assert get_subagent_count("@0") == 0
268
- assert "@0" not in _active_subagents
419
+ with patch("ccgram.handlers.message_queue.enqueue_status_update"):
420
+ event = _make_event(
421
+ event_type="SubagentStop", data={"subagent_id": "sub-1"}
422
+ )
423
+ await dispatch_hook_event(event, bot)
424
+ assert get_subagent_names("@0") == []
425
+ assert "@0" not in _active_subagents
426
+
427
+ async def test_unknown_id_no_notification(self, monkeypatch) -> None:
428
+ monkeypatch.setattr(
429
+ "ccgram.handlers.hook_events.session_manager.iter_thread_bindings",
430
+ lambda: iter([(100, 42, "@0")]),
431
+ )
432
+ bot = AsyncMock(spec=Bot)
433
+ with patch(
434
+ "ccgram.handlers.message_queue.enqueue_status_update"
435
+ ) as mock_enqueue:
436
+ event = _make_event(
437
+ event_type="SubagentStop", data={"subagent_id": "never-seen"}
438
+ )
439
+ await dispatch_hook_event(event, bot)
440
+ mock_enqueue.assert_not_called()
269
441
 
270
442
 
271
443
  class TestHandleTeammateIdle:
@@ -383,6 +555,9 @@ class TestHandleStopFailure:
383
555
 
384
556
 
385
557
  class TestHandleSessionEnd:
558
+ def setup_method(self) -> None:
559
+ _active_subagents.clear()
560
+
386
561
  async def test_transitions_to_done(self, monkeypatch) -> None:
387
562
  monkeypatch.setattr(
388
563
  "ccgram.handlers.hook_events.session_manager.iter_thread_bindings",
@@ -415,6 +590,33 @@ class TestHandleSessionEnd:
415
590
  mock_enqueue.assert_called_once_with(bot, 100, "@0", None, thread_id=42)
416
591
  mock_clear_session.assert_called_once_with("@0")
417
592
 
593
+ async def test_clears_subagents_on_session_end(self, monkeypatch) -> None:
594
+ monkeypatch.setattr(
595
+ "ccgram.handlers.hook_events.session_manager.iter_thread_bindings",
596
+ lambda: iter([(100, 42, "@0")]),
597
+ )
598
+ _active_subagents["@0"] = {"sub-1": "researcher"}
599
+ bot = AsyncMock(spec=Bot)
600
+ with (
601
+ patch(
602
+ "ccgram.handlers.hook_events.session_manager.resolve_chat_id",
603
+ return_value=-100,
604
+ ),
605
+ patch(
606
+ "ccgram.handlers.hook_events.session_manager.get_display_name",
607
+ return_value="project",
608
+ ),
609
+ patch(
610
+ "ccgram.handlers.hook_events.session_manager.clear_window_session",
611
+ ),
612
+ patch("ccgram.handlers.topic_emoji.update_topic_emoji"),
613
+ patch("ccgram.handlers.message_queue.enqueue_status_update"),
614
+ patch("ccgram.handlers.status_polling.clear_seen_status"),
615
+ ):
616
+ event = _make_event(event_type="SessionEnd", data={"reason": "clear"})
617
+ await dispatch_hook_event(event, bot)
618
+ assert get_subagent_names("@0") == []
619
+
418
620
  async def test_no_users_skips(self, monkeypatch) -> None:
419
621
  monkeypatch.setattr(
420
622
  "ccgram.handlers.hook_events.session_manager.iter_thread_bindings",