novacode-cli 1.2.0__py3-none-any.whl

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 (691) hide show
  1. novacode_cli/__init__.py +29 -0
  2. novacode_cli/__main__.py +6 -0
  3. novacode_cli/_lazy_heavy.py +99 -0
  4. novacode_cli/_version.py +3 -0
  5. novacode_cli/agent_stream.py +178 -0
  6. novacode_cli/agents/agent_file.py +250 -0
  7. novacode_cli/agents/agent_mailbox.py +120 -0
  8. novacode_cli/agents/approval_decisions.py +146 -0
  9. novacode_cli/agents/arg_repair.py +196 -0
  10. novacode_cli/agents/async_agents/__init__.py +6 -0
  11. novacode_cli/agents/async_agents/_config.py +213 -0
  12. novacode_cli/agents/async_agents/_mcp_tools.py +192 -0
  13. novacode_cli/agents/async_agents/_specialist.py +84 -0
  14. novacode_cli/agents/async_agents/_test_commands.py +89 -0
  15. novacode_cli/agents/async_agents/_tools.py +432 -0
  16. novacode_cli/agents/async_agents/general_purpose.py +42 -0
  17. novacode_cli/agents/async_agents/langgraph.json +9 -0
  18. novacode_cli/agents/async_agents/research.py +22 -0
  19. novacode_cli/agents/async_context.py +303 -0
  20. novacode_cli/agents/async_on_demand.py +52 -0
  21. novacode_cli/agents/async_task_errors.py +75 -0
  22. novacode_cli/agents/async_workspace.py +27 -0
  23. novacode_cli/agents/attribution.py +165 -0
  24. novacode_cli/agents/browser_mode.py +119 -0
  25. novacode_cli/agents/btw_agent.py +71 -0
  26. novacode_cli/agents/caveman.py +352 -0
  27. novacode_cli/agents/caveman_support.py +370 -0
  28. novacode_cli/agents/code_mode.py +774 -0
  29. novacode_cli/agents/code_runtime.py +160 -0
  30. novacode_cli/agents/context_compaction.py +1339 -0
  31. novacode_cli/agents/core_agent.py +2710 -0
  32. novacode_cli/agents/default_subagents/__init__.py +24 -0
  33. novacode_cli/agents/default_subagents/async_subagents.py +296 -0
  34. novacode_cli/agents/default_subagents/builtin/browser-automation-agent.md +110 -0
  35. novacode_cli/agents/default_subagents/builtin/bug-fix-agent.md +22 -0
  36. novacode_cli/agents/default_subagents/builtin/bug-investigation-agent.md +42 -0
  37. novacode_cli/agents/default_subagents/builtin/code-explorer.md +37 -0
  38. novacode_cli/agents/default_subagents/builtin/code-review-agent.md +33 -0
  39. novacode_cli/agents/default_subagents/builtin/dependency-audit-agent.md +34 -0
  40. novacode_cli/agents/default_subagents/builtin/documentation-update-agent.md +46 -0
  41. novacode_cli/agents/default_subagents/builtin/performance-audit-agent.md +41 -0
  42. novacode_cli/agents/default_subagents/builtin/plan-scout-agent.md +64 -0
  43. novacode_cli/agents/default_subagents/builtin/refactoring-agent.md +35 -0
  44. novacode_cli/agents/default_subagents/builtin/refactoring-specialist-agent.md +175 -0
  45. novacode_cli/agents/default_subagents/builtin/security-audit-agent.md +96 -0
  46. novacode_cli/agents/default_subagents/builtin/test-generation-agent.md +33 -0
  47. novacode_cli/agents/default_subagents/builtin/test-runner-agent.md +55 -0
  48. novacode_cli/agents/default_subagents/prompt.py +88 -0
  49. novacode_cli/agents/default_subagents/subagents.py +138 -0
  50. novacode_cli/agents/dream_agent.py +93 -0
  51. novacode_cli/agents/edit_diagnostics.py +362 -0
  52. novacode_cli/agents/edit_fallback.py +62 -0
  53. novacode_cli/agents/handoff.py +166 -0
  54. novacode_cli/agents/invocation.py +152 -0
  55. novacode_cli/agents/model_budget.py +42 -0
  56. novacode_cli/agents/model_retry.py +113 -0
  57. novacode_cli/agents/model_router.py +420 -0
  58. novacode_cli/agents/openai_decisions.py +186 -0
  59. novacode_cli/agents/plan_agent/__init__.py +12 -0
  60. novacode_cli/agents/plan_agent/plan_agent.py +357 -0
  61. novacode_cli/agents/plan_agent/plan_mode_middleware.py +64 -0
  62. novacode_cli/agents/plan_execution.py +237 -0
  63. novacode_cli/agents/ponytail.py +66 -0
  64. novacode_cli/agents/prime.py +274 -0
  65. novacode_cli/agents/provider_caching.py +99 -0
  66. novacode_cli/agents/server_launcher.py +904 -0
  67. novacode_cli/agents/strict_tools.py +384 -0
  68. novacode_cli/agents/task_discipline.py +294 -0
  69. novacode_cli/agents/tool_call_repair.py +290 -0
  70. novacode_cli/agents/tool_offload.py +291 -0
  71. novacode_cli/agents/tool_search.py +806 -0
  72. novacode_cli/agents/tool_search_variants.py +106 -0
  73. novacode_cli/agents/tool_verdicts.py +1194 -0
  74. novacode_cli/agents/user_async_agents.py +312 -0
  75. novacode_cli/artifacts/__init__.py +10 -0
  76. novacode_cli/artifacts/registry.py +278 -0
  77. novacode_cli/artifacts/server.py +426 -0
  78. novacode_cli/assets/Nova.png +0 -0
  79. novacode_cli/assets/nova-avatar.png +0 -0
  80. novacode_cli/audio/__init__.py +301 -0
  81. novacode_cli/audio/capture.py +137 -0
  82. novacode_cli/audio/downloads.py +273 -0
  83. novacode_cli/audio/pipeline.py +391 -0
  84. novacode_cli/audio/playback.py +87 -0
  85. novacode_cli/audio/providers.py +208 -0
  86. novacode_cli/audio/speakable.py +78 -0
  87. novacode_cli/audio/stt.py +132 -0
  88. novacode_cli/audio/stt_deepgram.py +69 -0
  89. novacode_cli/audio/stt_elevenlabs.py +89 -0
  90. novacode_cli/audio/stt_parakeet.py +94 -0
  91. novacode_cli/audio/stt_whistle.py +61 -0
  92. novacode_cli/audio/summarize.py +92 -0
  93. novacode_cli/audio/tts.py +85 -0
  94. novacode_cli/audio/tts_kokoro.py +73 -0
  95. novacode_cli/audio/tts_orpheus.py +180 -0
  96. novacode_cli/audio/tts_pocket.py +131 -0
  97. novacode_cli/audio/tts_sano.py +69 -0
  98. novacode_cli/audio/vad.py +145 -0
  99. novacode_cli/backends/__init__.py +17 -0
  100. novacode_cli/backends/filesystem.py +894 -0
  101. novacode_cli/backends/native_search.py +92 -0
  102. novacode_cli/bootstrap/__init__.py +9 -0
  103. novacode_cli/bootstrap/env_snapshot.py +240 -0
  104. novacode_cli/bootstrap/steering.py +464 -0
  105. novacode_cli/bootstrap/vision_router.py +604 -0
  106. novacode_cli/brand.py +362 -0
  107. novacode_cli/cli_args.py +718 -0
  108. novacode_cli/commands/__init__.py +237 -0
  109. novacode_cli/commands/agents_commands.py +231 -0
  110. novacode_cli/commands/auth_handler.py +40 -0
  111. novacode_cli/commands/browser_use_handler.py +75 -0
  112. novacode_cli/commands/cache_handler.py +32 -0
  113. novacode_cli/commands/chat_handler.py +1357 -0
  114. novacode_cli/commands/ci_fix.py +171 -0
  115. novacode_cli/commands/claude_plan.py +331 -0
  116. novacode_cli/commands/codex_plan.py +195 -0
  117. novacode_cli/commands/commands.py +1113 -0
  118. novacode_cli/commands/create_handler.py +124 -0
  119. novacode_cli/commands/create_server.py +1887 -0
  120. novacode_cli/commands/cron_handler.py +119 -0
  121. novacode_cli/commands/dream_handler.py +203 -0
  122. novacode_cli/commands/effort_handler.py +65 -0
  123. novacode_cli/commands/evolution_handler.py +214 -0
  124. novacode_cli/commands/file_commands.py +381 -0
  125. novacode_cli/commands/handoff_handler.py +68 -0
  126. novacode_cli/commands/hooks_handler.py +269 -0
  127. novacode_cli/commands/init_handler.py +146 -0
  128. novacode_cli/commands/learning_handler.py +160 -0
  129. novacode_cli/commands/lite_mode_handler.py +76 -0
  130. novacode_cli/commands/log_commands.py +363 -0
  131. novacode_cli/commands/mcp_handler.py +29 -0
  132. novacode_cli/commands/model_handler.py +29 -0
  133. novacode_cli/commands/notifications_handler.py +158 -0
  134. novacode_cli/commands/onboard_handler.py +121 -0
  135. novacode_cli/commands/plan_handler.py +440 -0
  136. novacode_cli/commands/planner_usage.py +250 -0
  137. novacode_cli/commands/plugin_install_handler.py +38 -0
  138. novacode_cli/commands/plugins_handler.py +35 -0
  139. novacode_cli/commands/prime_handler.py +455 -0
  140. novacode_cli/commands/prompt_handler.py +149 -0
  141. novacode_cli/commands/provider_usage.py +160 -0
  142. novacode_cli/commands/ralph_events.py +106 -0
  143. novacode_cli/commands/ralph_handler.py +1293 -0
  144. novacode_cli/commands/refine_handler.py +213 -0
  145. novacode_cli/commands/research_handler.py +202 -0
  146. novacode_cli/commands/rootapp_remote.py +83 -0
  147. novacode_cli/commands/router_handler.py +36 -0
  148. novacode_cli/commands/server_commands.py +249 -0
  149. novacode_cli/commands/session_commands.py +203 -0
  150. novacode_cli/commands/share_handler.py +450 -0
  151. novacode_cli/commands/side_commands.py +366 -0
  152. novacode_cli/commands/skill_invoke.py +420 -0
  153. novacode_cli/commands/skills_commands.py +48 -0
  154. novacode_cli/commands/trace_handler.py +322 -0
  155. novacode_cli/commands/trello_handler.py +226 -0
  156. novacode_cli/commands/trello_remote.py +177 -0
  157. novacode_cli/commands/trello_server.py +1214 -0
  158. novacode_cli/commands/vision_handler.py +127 -0
  159. novacode_cli/commands/voice_handler.py +447 -0
  160. novacode_cli/commands/webhook_handler.py +134 -0
  161. novacode_cli/commands/wiki_commands.py +227 -0
  162. novacode_cli/compaction.py +753 -0
  163. novacode_cli/computation_cache.py +159 -0
  164. novacode_cli/computer/__init__.py +1 -0
  165. novacode_cli/computer/persistent.py +234 -0
  166. novacode_cli/computer/preview.py +253 -0
  167. novacode_cli/computer/runtime.py +474 -0
  168. novacode_cli/computer/windows_console.py +152 -0
  169. novacode_cli/computer/windows_qemu.py +211 -0
  170. novacode_cli/config/browser_mode.py +159 -0
  171. novacode_cli/config/catalog_models.py +95 -0
  172. novacode_cli/config/config.py +1388 -0
  173. novacode_cli/config/credentials.py +540 -0
  174. novacode_cli/config/google_oauth_auth.py +281 -0
  175. novacode_cli/config/model_capabilities.py +343 -0
  176. novacode_cli/config/model_catalog.py +275 -0
  177. novacode_cli/config/model_create.py +1325 -0
  178. novacode_cli/config/model_manager.py +695 -0
  179. novacode_cli/config/nova_config.py +1283 -0
  180. novacode_cli/config/openai_chatgpt_auth.py +384 -0
  181. novacode_cli/config/opencode_gateway.py +25 -0
  182. novacode_cli/config/plan_mode.py +188 -0
  183. novacode_cli/config/provider_auth.py +438 -0
  184. novacode_cli/config/provider_catalog.json +5127 -0
  185. novacode_cli/config/provider_registry.py +38 -0
  186. novacode_cli/config/provider_transports.py +258 -0
  187. novacode_cli/config/role_models.py +175 -0
  188. novacode_cli/context/__init__.py +50 -0
  189. novacode_cli/context/_analysis.py +799 -0
  190. novacode_cli/context/_budget.py +116 -0
  191. novacode_cli/context/_conversation.py +73 -0
  192. novacode_cli/context/_dynamic.py +264 -0
  193. novacode_cli/context/_model_config.py +380 -0
  194. novacode_cli/context/_models_dev.py +99 -0
  195. novacode_cli/context/archives.py +248 -0
  196. novacode_cli/context/estimates.py +65 -0
  197. novacode_cli/context/history.py +17 -0
  198. novacode_cli/context/knowledge_cache.py +292 -0
  199. novacode_cli/context/manager.py +101 -0
  200. novacode_cli/context/observations.py +65 -0
  201. novacode_cli/context/pressure.py +168 -0
  202. novacode_cli/context/provider_accounting.py +109 -0
  203. novacode_cli/context/snapshot.py +493 -0
  204. novacode_cli/context/strategies.py +279 -0
  205. novacode_cli/context/task_progress.py +122 -0
  206. novacode_cli/core/__init__.py +0 -0
  207. novacode_cli/core/agent_loop.py +1821 -0
  208. novacode_cli/core/autonomous_loop.py +277 -0
  209. novacode_cli/core/code_approvals.py +57 -0
  210. novacode_cli/core/input_preparation.py +276 -0
  211. novacode_cli/core/reasoning.py +67 -0
  212. novacode_cli/core/streaming.py +326 -0
  213. novacode_cli/core/subagent_tasks.py +696 -0
  214. novacode_cli/core/subagent_tracking.py +235 -0
  215. novacode_cli/core/tool_results.py +116 -0
  216. novacode_cli/core/verification_loop.py +272 -0
  217. novacode_cli/council.py +595 -0
  218. novacode_cli/council_consensus.py +115 -0
  219. novacode_cli/council_decision.py +157 -0
  220. novacode_cli/council_planner.py +1421 -0
  221. novacode_cli/council_settings.py +208 -0
  222. novacode_cli/council_web_settings.py +41 -0
  223. novacode_cli/council_workspace.py +56 -0
  224. novacode_cli/cowork/__init__.py +10 -0
  225. novacode_cli/cowork/broker_middleware.py +162 -0
  226. novacode_cli/cowork/launcher.py +102 -0
  227. novacode_cli/cowork/policy.py +284 -0
  228. novacode_cli/cowork/ui.py +826 -0
  229. novacode_cli/daemons/__init__.py +1 -0
  230. novacode_cli/daemons/registry.py +342 -0
  231. novacode_cli/desktop_notifications.py +302 -0
  232. novacode_cli/doctor.py +251 -0
  233. novacode_cli/embeddings.py +109 -0
  234. novacode_cli/entrypoint.py +56 -0
  235. novacode_cli/errors/__init__.py +19 -0
  236. novacode_cli/errors/handlers.py +648 -0
  237. novacode_cli/errors/provider_errors.py +249 -0
  238. novacode_cli/errors/taxonomy.py +80 -0
  239. novacode_cli/events.py +179 -0
  240. novacode_cli/file_ops.py +714 -0
  241. novacode_cli/git_safety.py +281 -0
  242. novacode_cli/handoff.py +383 -0
  243. novacode_cli/headless/__init__.py +18 -0
  244. novacode_cli/headless/input.py +58 -0
  245. novacode_cli/headless/output.py +224 -0
  246. novacode_cli/headless/pipe.py +532 -0
  247. novacode_cli/headless/runner.py +461 -0
  248. novacode_cli/hermes/__init__.py +39 -0
  249. novacode_cli/hermes/activation.py +297 -0
  250. novacode_cli/hermes/config.py +129 -0
  251. novacode_cli/hermes/curator.py +199 -0
  252. novacode_cli/hermes/evolution.py +374 -0
  253. novacode_cli/hermes/ledger.py +364 -0
  254. novacode_cli/hermes/memory_tiers.py +969 -0
  255. novacode_cli/hermes/middleware.py +678 -0
  256. novacode_cli/hermes/overview.py +174 -0
  257. novacode_cli/hermes/prompt_evolution.py +503 -0
  258. novacode_cli/hermes/refine_loop.py +643 -0
  259. novacode_cli/hermes/refinement_log.py +190 -0
  260. novacode_cli/hermes/review.py +674 -0
  261. novacode_cli/hermes/skill_debate.py +135 -0
  262. novacode_cli/hermes/skill_discovery.py +450 -0
  263. novacode_cli/hermes/skill_manager.py +335 -0
  264. novacode_cli/hermes/skill_proposals.py +43 -0
  265. novacode_cli/hermes/test_gaming.py +119 -0
  266. novacode_cli/hermes/tracker.py +435 -0
  267. novacode_cli/hermes/tuner.py +206 -0
  268. novacode_cli/hermes/verification_memory.py +134 -0
  269. novacode_cli/hermes/verifier.py +277 -0
  270. novacode_cli/hitl/__init__.py +22 -0
  271. novacode_cli/hitl/interrupts.py +493 -0
  272. novacode_cli/hooks.py +359 -0
  273. novacode_cli/image_utils.py +517 -0
  274. novacode_cli/init/__init__.py +7 -0
  275. novacode_cli/init/events.py +43 -0
  276. novacode_cli/input_utils.py +526 -0
  277. novacode_cli/install_commands.py +87 -0
  278. novacode_cli/integrations/__init__.py +82 -0
  279. novacode_cli/integrations/daytona.py +122 -0
  280. novacode_cli/integrations/docker.py +309 -0
  281. novacode_cli/integrations/host_path.py +111 -0
  282. novacode_cli/integrations/langsmith.py +151 -0
  283. novacode_cli/integrations/modal.py +134 -0
  284. novacode_cli/integrations/runloop.py +130 -0
  285. novacode_cli/integrations/sandbox_factory.py +1388 -0
  286. novacode_cli/integrations/sandbox_registry.py +312 -0
  287. novacode_cli/integrations/workdir_backend.py +589 -0
  288. novacode_cli/main.py +2078 -0
  289. novacode_cli/mcp/__init__.py +73 -0
  290. novacode_cli/mcp/auth.py +189 -0
  291. novacode_cli/mcp/client.py +571 -0
  292. novacode_cli/mcp/code_mode.py +5 -0
  293. novacode_cli/mcp/code_runtime.py +5 -0
  294. novacode_cli/mcp/commands.py +902 -0
  295. novacode_cli/mcp/config.py +406 -0
  296. novacode_cli/mcp/generic.py +128 -0
  297. novacode_cli/mcp/middleware.py +993 -0
  298. novacode_cli/mcp/presets.py +367 -0
  299. novacode_cli/memory/__init__.py +1 -0
  300. novacode_cli/memory/agent_memory.py +1312 -0
  301. novacode_cli/memory/builtin_lessons.py +60 -0
  302. novacode_cli/memory/dream_gates.py +192 -0
  303. novacode_cli/memory/limits.py +59 -0
  304. novacode_cli/memory/parsed_cache.py +106 -0
  305. novacode_cli/memory/recall_stats.py +97 -0
  306. novacode_cli/memory/semantic.py +116 -0
  307. novacode_cli/memory/store.py +335 -0
  308. novacode_cli/memory/wiki.py +274 -0
  309. novacode_cli/migrate.py +185 -0
  310. novacode_cli/onboarding.py +649 -0
  311. novacode_cli/onboarding_check.py +119 -0
  312. novacode_cli/path_approval.py +297 -0
  313. novacode_cli/plan_archive.py +148 -0
  314. novacode_cli/plugins/__init__.py +121 -0
  315. novacode_cli/plugins/claude_plugins.py +538 -0
  316. novacode_cli/plugins/cli.py +168 -0
  317. novacode_cli/plugins/components.py +413 -0
  318. novacode_cli/plugins/hooks.py +356 -0
  319. novacode_cli/plugins/loader.py +317 -0
  320. novacode_cli/plugins/marketplaces.py +199 -0
  321. novacode_cli/plugins/storage.py +70 -0
  322. novacode_cli/prime.py +759 -0
  323. novacode_cli/prime_factory/__init__.py +15 -0
  324. novacode_cli/prime_factory/container.py +198 -0
  325. novacode_cli/prime_factory/controller.py +565 -0
  326. novacode_cli/prime_factory/knowledge.py +261 -0
  327. novacode_cli/prime_factory/publication.py +218 -0
  328. novacode_cli/prime_factory/recovery.py +50 -0
  329. novacode_cli/prime_factory/research.py +370 -0
  330. novacode_cli/prime_factory/runner.py +608 -0
  331. novacode_cli/prime_factory/runtime.py +261 -0
  332. novacode_cli/prime_factory/state.py +201 -0
  333. novacode_cli/prime_factory/store.py +128 -0
  334. novacode_cli/prime_factory/worker.py +129 -0
  335. novacode_cli/prime_factory/workflow.py +899 -0
  336. novacode_cli/prime_factory/workspace.py +317 -0
  337. novacode_cli/prime_memory.py +290 -0
  338. novacode_cli/process_manager.py +585 -0
  339. novacode_cli/project_onboard.py +537 -0
  340. novacode_cli/prompts/System_Prompt_Nova.jinja +23 -0
  341. novacode_cli/prompts/__init__.py +168 -0
  342. novacode_cli/prompts/agent_definitions.jinja +11 -0
  343. novacode_cli/prompts/agent_generation.jinja +125 -0
  344. novacode_cli/prompts/async_agents/general_purpose.jinja +22 -0
  345. novacode_cli/prompts/claude_plan_mode.jinja +12 -0
  346. novacode_cli/prompts/coding_workflow.jinja +18 -0
  347. novacode_cli/prompts/computer_use.jinja +17 -0
  348. novacode_cli/prompts/core_agent_system.jinja +81 -0
  349. novacode_cli/prompts/delegation.jinja +15 -0
  350. novacode_cli/prompts/desktop_power.jinja +4 -0
  351. novacode_cli/prompts/file_paths.jinja +15 -0
  352. novacode_cli/prompts/file_tracker.jinja +23 -0
  353. novacode_cli/prompts/init_agents_md.jinja +56 -0
  354. novacode_cli/prompts/init_exploration.jinja +174 -0
  355. novacode_cli/prompts/init_semantic_orchestrate.jinja +37 -0
  356. novacode_cli/prompts/instruction_scope.jinja +5 -0
  357. novacode_cli/prompts/learnings_extraction.jinja +45 -0
  358. novacode_cli/prompts/longterm_memory.jinja +54 -0
  359. novacode_cli/prompts/mcp.jinja +84 -0
  360. novacode_cli/prompts/memory_consolidation.jinja +40 -0
  361. novacode_cli/prompts/nova_evolution.jinja +73 -0
  362. novacode_cli/prompts/nova_review.jinja +158 -0
  363. novacode_cli/prompts/nova_skill_debate.jinja +36 -0
  364. novacode_cli/prompts/nova_standing_instructions.jinja +26 -0
  365. novacode_cli/prompts/nova_verify.jinja +100 -0
  366. novacode_cli/prompts/onboard_dashboard.jinja +378 -0
  367. novacode_cli/prompts/plan_agent.jinja +87 -0
  368. novacode_cli/prompts/plan_scouts.jinja +5 -0
  369. novacode_cli/prompts/plan_storage.jinja +8 -0
  370. novacode_cli/prompts/planning.jinja +82 -0
  371. novacode_cli/prompts/ponytail/LICENSE +21 -0
  372. novacode_cli/prompts/ponytail/UPSTREAM.json +15 -0
  373. novacode_cli/prompts/ponytail/hooks/ponytail-config.js +175 -0
  374. novacode_cli/prompts/ponytail/hooks/ponytail-instructions.js +98 -0
  375. novacode_cli/prompts/ponytail/skills/ponytail/SKILL.md +52 -0
  376. novacode_cli/prompts/ponytail/skills/ponytail-audit/SKILL.md +103 -0
  377. novacode_cli/prompts/ponytail/skills/ponytail-debt/SKILL.md +44 -0
  378. novacode_cli/prompts/ponytail/skills/ponytail-gain/SKILL.md +52 -0
  379. novacode_cli/prompts/ponytail/skills/ponytail-help/SKILL.md +76 -0
  380. novacode_cli/prompts/ponytail/skills/ponytail-review/SKILL.md +101 -0
  381. novacode_cli/prompts/prompt_evolution.jinja +27 -0
  382. novacode_cli/prompts/ralph_iteration.jinja +75 -0
  383. novacode_cli/prompts/refine_gate.jinja +31 -0
  384. novacode_cli/prompts/refine_plan.jinja +64 -0
  385. novacode_cli/prompts/refine_review.jinja +34 -0
  386. novacode_cli/prompts/research_delivery.jinja +9 -0
  387. novacode_cli/prompts/research_swarm.jinja +130 -0
  388. novacode_cli/prompts/response_style.jinja +5 -0
  389. novacode_cli/prompts/session_summarization.jinja +86 -0
  390. novacode_cli/prompts/skill_creation.jinja +85 -0
  391. novacode_cli/prompts/subagents/fact_checker.jinja +48 -0
  392. novacode_cli/prompts/subagents/financial_analyst.jinja +50 -0
  393. novacode_cli/prompts/subagents/literature_reviewer.jinja +52 -0
  394. novacode_cli/prompts/subagents/market_analyst.jinja +48 -0
  395. novacode_cli/prompts/subagents/research_synthesizer.jinja +57 -0
  396. novacode_cli/prompts/subagents/technical_researcher.jinja +56 -0
  397. novacode_cli/prompts/subagents/web_researcher.jinja +46 -0
  398. novacode_cli/prompts/summarization.jinja +107 -0
  399. novacode_cli/prompts/summarization_answers.jinja +23 -0
  400. novacode_cli/prompts/summarization_questions.jinja +32 -0
  401. novacode_cli/prompts/vision_caption.jinja +21 -0
  402. novacode_cli/prompts/voice_summary.jinja +65 -0
  403. novacode_cli/prompts/wiki_file.jinja +57 -0
  404. novacode_cli/prompts/wiki_ingest.jinja +87 -0
  405. novacode_cli/recovery.py +383 -0
  406. novacode_cli/remote/__init__.py +45 -0
  407. novacode_cli/remote/approvals.py +282 -0
  408. novacode_cli/remote/async_task_clients.py +97 -0
  409. novacode_cli/remote/async_task_watcher.py +240 -0
  410. novacode_cli/remote/bridge.py +905 -0
  411. novacode_cli/remote/command_menu.py +26 -0
  412. novacode_cli/remote/command_ui.py +243 -0
  413. novacode_cli/remote/config.py +199 -0
  414. novacode_cli/remote/discord_bridge.py +530 -0
  415. novacode_cli/remote/discord_format.py +31 -0
  416. novacode_cli/remote/discord_threads.py +225 -0
  417. novacode_cli/remote/images.py +97 -0
  418. novacode_cli/remote/processor.py +601 -0
  419. novacode_cli/remote/rootapp_bot/bot.cjs +16 -0
  420. novacode_cli/remote/rootapp_bot/package-lock.json +2683 -0
  421. novacode_cli/remote/rootapp_bot/package.json +18 -0
  422. novacode_cli/remote/rootapp_bot/transport.cjs +80 -0
  423. novacode_cli/remote/rootapp_bridge.py +370 -0
  424. novacode_cli/remote/rootapp_setup.py +89 -0
  425. novacode_cli/remote/routing.py +103 -0
  426. novacode_cli/remote/scheduler.py +302 -0
  427. novacode_cli/remote/session.py +164 -0
  428. novacode_cli/remote/status.py +411 -0
  429. novacode_cli/remote/streaming.py +188 -0
  430. novacode_cli/remote/telegram_bridge.py +1118 -0
  431. novacode_cli/remote/telegram_format.py +215 -0
  432. novacode_cli/remote/telegram_hub.py +256 -0
  433. novacode_cli/remote/voice_notes.py +120 -0
  434. novacode_cli/remote/webhook_adapters.py +167 -0
  435. novacode_cli/remote/webhook_server.py +154 -0
  436. novacode_cli/rewind.py +150 -0
  437. novacode_cli/rooms/__init__.py +1 -0
  438. novacode_cli/rooms/bridge.py +86 -0
  439. novacode_cli/rooms/cli.py +231 -0
  440. novacode_cli/rooms/nova.py +227 -0
  441. novacode_cli/rooms/runtime.py +299 -0
  442. novacode_cli/rooms/store.py +538 -0
  443. novacode_cli/rooms/workspace.py +94 -0
  444. novacode_cli/security/__init__.py +29 -0
  445. novacode_cli/security/approval_context.py +45 -0
  446. novacode_cli/security/delegated_approval.py +68 -0
  447. novacode_cli/security/local_http.py +85 -0
  448. novacode_cli/security/middleware.py +87 -0
  449. novacode_cli/security/paths.py +35 -0
  450. novacode_cli/security/policy.py +535 -0
  451. novacode_cli/security/policy_writer.py +90 -0
  452. novacode_cli/security/prompt_injection.py +203 -0
  453. novacode_cli/security/remember.py +81 -0
  454. novacode_cli/security/rule_synthesis.py +116 -0
  455. novacode_cli/security/secret_files.py +61 -0
  456. novacode_cli/security/session_allow.py +114 -0
  457. novacode_cli/security/unicode_security.py +516 -0
  458. novacode_cli/security/validator.py +173 -0
  459. novacode_cli/server/__init__.py +9 -0
  460. novacode_cli/server/app.py +632 -0
  461. novacode_cli/server/event_adapter.py +189 -0
  462. novacode_cli/server/session_manager.py +90 -0
  463. novacode_cli/server_main.py +64 -0
  464. novacode_cli/server_runner/dev_server.py +530 -0
  465. novacode_cli/server_runner/test_runner.py +470 -0
  466. novacode_cli/session/__init__.py +1 -0
  467. novacode_cli/session/adapters/__init__.py +240 -0
  468. novacode_cli/session/adapters/claude_code.py +102 -0
  469. novacode_cli/session/adapters/codex.py +77 -0
  470. novacode_cli/session/adapters/harness_native.py +75 -0
  471. novacode_cli/session/checkpoint_coordinator.py +448 -0
  472. novacode_cli/session/context_restore.py +25 -0
  473. novacode_cli/session/history_export.py +228 -0
  474. novacode_cli/session/history_tree.py +174 -0
  475. novacode_cli/session/imported_context.py +267 -0
  476. novacode_cli/session/runtime_identity.py +32 -0
  477. novacode_cli/session/session_persistence.py +1168 -0
  478. novacode_cli/session/session_prompt_builder.py +279 -0
  479. novacode_cli/session/session_restore.py +351 -0
  480. novacode_cli/session/session_summarization.py +20 -0
  481. novacode_cli/session/transcript_journal.py +153 -0
  482. novacode_cli/sessions/__init__.py +11 -0
  483. novacode_cli/sessions/gateway.py +84 -0
  484. novacode_cli/sessions/launch.py +51 -0
  485. novacode_cli/sessions/lease.py +213 -0
  486. novacode_cli/sessions/messaging.py +255 -0
  487. novacode_cli/sessions/protocol.py +216 -0
  488. novacode_cli/sessions/resume.py +31 -0
  489. novacode_cli/sessions/supervisor.py +498 -0
  490. novacode_cli/sessions/tasks.py +73 -0
  491. novacode_cli/sessions/tui_messaging.py +413 -0
  492. novacode_cli/sessions/worker.py +652 -0
  493. novacode_cli/sessions/worktree.py +262 -0
  494. novacode_cli/shell/__init__.py +27 -0
  495. novacode_cli/shell/jobs.py +475 -0
  496. novacode_cli/shell/middleware.py +1568 -0
  497. novacode_cli/shell/os_sandbox.py +205 -0
  498. novacode_cli/shell/output.py +181 -0
  499. novacode_cli/shell/patterns.py +275 -0
  500. novacode_cli/shell/processes.py +87 -0
  501. novacode_cli/shell/utils.py +236 -0
  502. novacode_cli/skills/__init__.py +26 -0
  503. novacode_cli/skills/builtin/handoff/SKILL.md +15 -0
  504. novacode_cli/skills/curation_middleware.py +69 -0
  505. novacode_cli/skills/executable.py +161 -0
  506. novacode_cli/skills/libraries.py +102 -0
  507. novacode_cli/skills/load.py +248 -0
  508. novacode_cli/skills/prune.py +215 -0
  509. novacode_cli/skills/refreshing_middleware.py +896 -0
  510. novacode_cli/skills/retrieval.py +248 -0
  511. novacode_cli/skills/runtime.py +118 -0
  512. novacode_cli/skills/schema.py +254 -0
  513. novacode_cli/skills/skill_creation.py +1549 -0
  514. novacode_cli/skills/skill_lock.py +75 -0
  515. novacode_cli/skills/skill_system_prompt.py +16 -0
  516. novacode_cli/skills/skills_prefs.py +126 -0
  517. novacode_cli/skills/tool_resolver.py +31 -0
  518. novacode_cli/skills/upstream.py +50 -0
  519. novacode_cli/skills/versioning.py +291 -0
  520. novacode_cli/states/Session.py +847 -0
  521. novacode_cli/states/__init__.py +31 -0
  522. novacode_cli/states/slices/__init__.py +17 -0
  523. novacode_cli/states/slices/agent_runtime.py +249 -0
  524. novacode_cli/states/slices/background_tasks.py +21 -0
  525. novacode_cli/states/slices/notifications.py +74 -0
  526. novacode_cli/states/slices/remote_bridge.py +56 -0
  527. novacode_cli/states/slices/ui_settings.py +49 -0
  528. novacode_cli/states/slices/wiki.py +25 -0
  529. novacode_cli/teammates/__init__.py +1 -0
  530. novacode_cli/teammates/controller.py +557 -0
  531. novacode_cli/teammates/graph.py +104 -0
  532. novacode_cli/teammates/registry.py +175 -0
  533. novacode_cli/teammates/service.py +254 -0
  534. novacode_cli/teammates/worker.py +19 -0
  535. novacode_cli/teams/__init__.py +1 -0
  536. novacode_cli/teams/adapters/__init__.py +1 -0
  537. novacode_cli/teams/adapters/container.py +316 -0
  538. novacode_cli/teams/adapters/inprocess.py +206 -0
  539. novacode_cli/teams/adapters/process.py +364 -0
  540. novacode_cli/teams/bootstrap.py +246 -0
  541. novacode_cli/teams/container_git.py +67 -0
  542. novacode_cli/teams/context.py +34 -0
  543. novacode_cli/teams/coordinator.py +815 -0
  544. novacode_cli/teams/gateway.py +200 -0
  545. novacode_cli/teams/host.py +64 -0
  546. novacode_cli/teams/legacy.py +52 -0
  547. novacode_cli/teams/middleware.py +176 -0
  548. novacode_cli/teams/models.py +104 -0
  549. novacode_cli/teams/output.py +47 -0
  550. novacode_cli/teams/protocol.py +50 -0
  551. novacode_cli/teams/recovery.py +80 -0
  552. novacode_cli/teams/scheduler.py +44 -0
  553. novacode_cli/teams/store.py +631 -0
  554. novacode_cli/teams/worker.py +251 -0
  555. novacode_cli/teams/workspace.py +100 -0
  556. novacode_cli/token_utils.py +256 -0
  557. novacode_cli/tools/__init__.py +104 -0
  558. novacode_cli/tools/_shared.py +97 -0
  559. novacode_cli/tools/_types.py +24 -0
  560. novacode_cli/tools/artifact_tools.py +137 -0
  561. novacode_cli/tools/code_search_tools.py +330 -0
  562. novacode_cli/tools/computer_tools.py +77 -0
  563. novacode_cli/tools/cron_tools.py +65 -0
  564. novacode_cli/tools/daemon_tool.py +290 -0
  565. novacode_cli/tools/desktop_tools.py +161 -0
  566. novacode_cli/tools/fetch_tools.py +530 -0
  567. novacode_cli/tools/http_tools.py +279 -0
  568. novacode_cli/tools/inspection_tools.py +107 -0
  569. novacode_cli/tools/job_tools.py +143 -0
  570. novacode_cli/tools/knowledge_tools.py +427 -0
  571. novacode_cli/tools/memory_tools.py +461 -0
  572. novacode_cli/tools/memory_wiki_tools.py +40 -0
  573. novacode_cli/tools/notebook_tools.py +238 -0
  574. novacode_cli/tools/oracle_tool.py +83 -0
  575. novacode_cli/tools/plan_archive_tools.py +80 -0
  576. novacode_cli/tools/plan_mode_tools.py +315 -0
  577. novacode_cli/tools/python_kernel_loop.py +165 -0
  578. novacode_cli/tools/python_kernel_tool.py +222 -0
  579. novacode_cli/tools/reflection_tools.py +34 -0
  580. novacode_cli/tools/scraper_tools.py +377 -0
  581. novacode_cli/tools/skill_tools.py +275 -0
  582. novacode_cli/tools/speak_tool.py +51 -0
  583. novacode_cli/tools/store_memory_tools.py +227 -0
  584. novacode_cli/tools/ui_tools.py +62 -0
  585. novacode_cli/tools/waiter_tools.py +408 -0
  586. novacode_cli/tools/web_tools.py +349 -0
  587. novacode_cli/tools/wiki_tools.py +152 -0
  588. novacode_cli/tools/worktree_tools.py +219 -0
  589. novacode_cli/tracking/__init__.py +10 -0
  590. novacode_cli/tracking/file_tracker.py +826 -0
  591. novacode_cli/tracking/local_metrics.py +170 -0
  592. novacode_cli/tracking/loop_guard.py +507 -0
  593. novacode_cli/tracking/request_metrics.py +60 -0
  594. novacode_cli/tracking/tracing.py +692 -0
  595. novacode_cli/tracking/usage_tree.py +677 -0
  596. novacode_cli/tracking/workspace_anchoring.py +231 -0
  597. novacode_cli/tui/__init__.py +11 -0
  598. novacode_cli/tui/animation_rate.py +9 -0
  599. novacode_cli/tui/animations.py +332 -0
  600. novacode_cli/tui/app.py +19315 -0
  601. novacode_cli/tui/auth_display.py +87 -0
  602. novacode_cli/tui/auth_screens.py +826 -0
  603. novacode_cli/tui/avatar.py +294 -0
  604. novacode_cli/tui/avatar_frames.py +21 -0
  605. novacode_cli/tui/background_tasks.py +35 -0
  606. novacode_cli/tui/browser_settings_screen.py +70 -0
  607. novacode_cli/tui/caveman_screen.py +185 -0
  608. novacode_cli/tui/council_settings_screen.py +182 -0
  609. novacode_cli/tui/council_view.py +535 -0
  610. novacode_cli/tui/decision_settings.py +268 -0
  611. novacode_cli/tui/diff_preview.py +199 -0
  612. novacode_cli/tui/gc_tuning.py +63 -0
  613. novacode_cli/tui/handoff_screen.py +206 -0
  614. novacode_cli/tui/harness.py +244 -0
  615. novacode_cli/tui/history_tree_screen.py +179 -0
  616. novacode_cli/tui/home.py +328 -0
  617. novacode_cli/tui/home_scene.py +94 -0
  618. novacode_cli/tui/icons.py +81 -0
  619. novacode_cli/tui/interface_styles.tcss +327 -0
  620. novacode_cli/tui/matrix3.py +108 -0
  621. novacode_cli/tui/mcp_tools_screen.py +131 -0
  622. novacode_cli/tui/motion.py +178 -0
  623. novacode_cli/tui/output_body.py +136 -0
  624. novacode_cli/tui/output_buffer.py +59 -0
  625. novacode_cli/tui/palette.py +334 -0
  626. novacode_cli/tui/pickers.py +237 -0
  627. novacode_cli/tui/prime_panel.py +506 -0
  628. novacode_cli/tui/provider_signin.py +213 -0
  629. novacode_cli/tui/pty_shell.py +159 -0
  630. novacode_cli/tui/reference_screens.py +444 -0
  631. novacode_cli/tui/resume_picker.py +346 -0
  632. novacode_cli/tui/rooms.py +597 -0
  633. novacode_cli/tui/screens.py +7088 -0
  634. novacode_cli/tui/session_import.py +333 -0
  635. novacode_cli/tui/session_pane.py +242 -0
  636. novacode_cli/tui/session_tab.py +42 -0
  637. novacode_cli/tui/session_transcript.py +118 -0
  638. novacode_cli/tui/shell_output.py +7 -0
  639. novacode_cli/tui/stall_watch.py +174 -0
  640. novacode_cli/tui/statusline.py +40 -0
  641. novacode_cli/tui/statusline_screen.py +91 -0
  642. novacode_cli/tui/teammate_updates.py +162 -0
  643. novacode_cli/tui/teammates.py +548 -0
  644. novacode_cli/tui/terminal.py +289 -0
  645. novacode_cli/tui/terminal_view.py +235 -0
  646. novacode_cli/tui/themes.py +118 -0
  647. novacode_cli/tui/update_notice.py +84 -0
  648. novacode_cli/tui/voice_downloads.py +113 -0
  649. novacode_cli/tui/widgets.py +2038 -0
  650. novacode_cli/tui/windows_input.py +38 -0
  651. novacode_cli/tui/workspace_approval.py +165 -0
  652. novacode_cli/ui/council_web.py +113 -0
  653. novacode_cli/ui/council_workspace.py +72 -0
  654. novacode_cli/ui/cowork_web.py +41 -0
  655. novacode_cli/ui/diff_highlight.py +160 -0
  656. novacode_cli/ui/execution.py +1396 -0
  657. novacode_cli/ui/exit_summary.py +65 -0
  658. novacode_cli/ui/hitl_approval.py +196 -0
  659. novacode_cli/ui/input_preparation.py +2 -0
  660. novacode_cli/ui/interrupt_handlers.py +215 -0
  661. novacode_cli/ui/question_prompt.py +151 -0
  662. novacode_cli/ui/status_phrases.py +157 -0
  663. novacode_cli/ui/stream_preview.py +66 -0
  664. novacode_cli/ui/streaming.py +2 -0
  665. novacode_cli/ui/subagent_tracking.py +2 -0
  666. novacode_cli/ui/ui_elements.py +1585 -0
  667. novacode_cli/ui/web_workspace.py +244 -0
  668. novacode_cli/ui_events.py +299 -0
  669. novacode_cli/update_ui.py +243 -0
  670. novacode_cli/updates.py +489 -0
  671. novacode_cli/utils/backend_patches.py +399 -0
  672. novacode_cli/utils/backend_paths.py +467 -0
  673. novacode_cli/utils/lazy_providers.py +51 -0
  674. novacode_cli/utils/model_info.py +223 -0
  675. novacode_cli/utils/pdf_extraction.py +183 -0
  676. novacode_cli/utils/startup.py +29 -0
  677. novacode_cli/utils/tasks.py +42 -0
  678. novacode_cli/utils/windows.py +9 -0
  679. novacode_cli/vixie/hook_adapter.py +127 -0
  680. novacode_cli/vixie/server.py +375 -0
  681. novacode_cli/wiki/__init__.py +15 -0
  682. novacode_cli/wiki/ask.py +56 -0
  683. novacode_cli/wiki/file.py +71 -0
  684. novacode_cli/wiki/ingest.py +184 -0
  685. novacode_cli/wiki/manager.py +261 -0
  686. novacode_cli-1.2.0.dist-info/METADATA +271 -0
  687. novacode_cli-1.2.0.dist-info/RECORD +691 -0
  688. novacode_cli-1.2.0.dist-info/WHEEL +5 -0
  689. novacode_cli-1.2.0.dist-info/entry_points.txt +2 -0
  690. novacode_cli-1.2.0.dist-info/licenses/LICENSE +21 -0
  691. novacode_cli-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,905 @@
1
+ """Remote bridges for Discord, Telegram and Rootapp.
2
+
3
+ Architecture
4
+ ------------
5
+ The bridge runs as background ``asyncio.Task``s inside the same event loop as
6
+ the CLI. When a message arrives on Discord or Telegram, it is placed on an
7
+ ``asyncio.Queue``. The main input loop (in ``main.py``) checks this queue
8
+ each turn and, if a remote message is pending, executes it through
9
+ ``execute_task()`` just like a local prompt.
10
+
11
+ Response chunks are captured from the agent's output and sent back to the
12
+ originating platform. For Discord, messages longer than 2 000 characters
13
+ are split into chunks. For Telegram the limit is 4 096.
14
+
15
+ Security
16
+ --------
17
+ - Only allowlisted channel / chat IDs receive responses.
18
+ - A ``/remote allow <ID>`` command manages the allowlist.
19
+ - Un-authorised messages are silently ignored (no acknowledgement leaked).
20
+
21
+ Lifecycle
22
+ ---------
23
+ - ``/remote start discord --token TOKEN --channel ID`` starts the bridge.
24
+ - ``/remote start telegram --token TOKEN --chat ID`` starts the bridge.
25
+ - ``/remote setup rootapp`` installs Root's optional SDK; ``start rootapp`` connects it.
26
+ - ``/remote stop`` gracefully shuts down all bridges.
27
+ - ``/remote status`` shows what's running.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import asyncio
33
+ import logging
34
+ import re
35
+ from dataclasses import dataclass, field
36
+ from enum import Enum
37
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable
38
+
39
+ if TYPE_CHECKING:
40
+ from novacode_cli.image_utils import ImageData
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Data types
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ class RemotePlatform(str, Enum):
51
+ """Supported remote platforms / message sources.
52
+
53
+ DISCORD, TELEGRAM and ROOTAPP are interactive chat bridges. CRON and WEBHOOK are
54
+ one-way *event sources* (Enhancement 3 / 5): they push ``RemoteMessage``s
55
+ onto the same queue but have no chat to reply to (their ``reply_fn`` is a
56
+ no-op). The processor handles them through the same queue.
57
+ """
58
+
59
+ DISCORD = "discord"
60
+ TELEGRAM = "telegram"
61
+ ROOTAPP = "rootapp"
62
+ CRON = "cron"
63
+ WEBHOOK = "webhook"
64
+
65
+
66
+ @dataclass
67
+ class RemoteMessage:
68
+ """A message received from a remote platform.
69
+
70
+ Attributes:
71
+ platform: Which platform the message came from.
72
+ chat_id: Platform-specific channel/chat ID (str for Discord, int for Telegram).
73
+ user_name: Display name of the sender.
74
+ text: The raw text of the message.
75
+ reply_fn: Async callable that sends a response back to the originating chat.
76
+ typing_fn: Optional async callable that triggers a "typing" indicator on the platform.
77
+ react_fn: Optional async callable that adds a reaction emoji to the user's
78
+ message (Discord). Best-effort; ``None`` on platforms without reactions.
79
+ edit_fn: Optional async callable ``(text, final=False)`` that creates a
80
+ single "live" reply message on first call and edits it in place on
81
+ subsequent calls — used to stream the agent's answer GPT-style without
82
+ flooding the chat or hitting rate limits. ``final=True`` signals the
83
+ last edit (the bridge may then apply richer formatting). ``None`` if
84
+ the platform/bridge doesn't support edit-in-place streaming.
85
+ """
86
+
87
+ platform: RemotePlatform
88
+ chat_id: str | int
89
+ user_name: str
90
+ text: str
91
+ reply_fn: Callable[[str], Awaitable[None]]
92
+ typing_fn: Callable[[], Awaitable[None]] | None = None
93
+ react_fn: Callable[[str], Awaitable[None]] | None = None
94
+ edit_fn: Callable[..., Awaitable[None]] | None = None
95
+ # Send a question; returns an async callable that removes it again (or
96
+ # None when the platform cannot). A question is only useful until it is
97
+ # answered — left in the chat it reads as still open.
98
+ ask_fn: Callable[[str], Awaitable[Callable[[], Awaitable[None]] | None]] | None = None
99
+ user_mention: str | None = None
100
+ #: Forum topic the message was posted in (Telegram), or None.
101
+ thread_id: int | None = None
102
+ #: The session that sent the message this one replies to, if known.
103
+ reply_to_owner: str | None = None
104
+ #: Set by the router before replying: ``{"sid": ..., "label": ...}``. The
105
+ #: bridge reads it when sending, to remember which session each outgoing
106
+ #: message belongs to (so replying to it reaches that session).
107
+ route: dict = field(default_factory=dict)
108
+ #: Independent editable answer; edit_fn remains the tool/progress message.
109
+ answer_edit_fn: Callable[..., Awaitable[None]] | None = None
110
+ #: Optional sender for local image previews in the final response.
111
+ image_preview_fn: Callable[[str, str | None], Awaitable[None]] | None = None
112
+ #: Stable platform account ID, used for scoped launch confirmations.
113
+ sender_id: str | int | None = None
114
+ #: Incoming message ID, for requester-specific Telegram picker replies.
115
+ message_id: int | None = None
116
+ #: Validated image data, kept separate from prompt text and credentials.
117
+ images: list[ImageData] = field(default_factory=list)
118
+ #: Revalidate approval senders against the live bridge and current authorization.
119
+ approval_check: Callable[[str], bool] | None = None
120
+ #: Optional native approval controls, bound to the broker's single-use nonce.
121
+ approval_fn: Callable[
122
+ [str, str], Awaitable[Callable[[], Awaitable[None]] | None]
123
+ ] | None = None
124
+
125
+
126
+ @dataclass
127
+ class BridgeConfig:
128
+ """Configuration for a single bridge instance.
129
+
130
+ Attributes:
131
+ platform: Which platform.
132
+ token: Bot token for the platform API.
133
+ chat_id: Specific channel/chat to listen on and respond in.
134
+ allowed_ids: Set of additional IDs that are allowed to interact.
135
+ ping: Whether to ping/mention the user when the task is done (Discord).
136
+ bot_id: Root Bot ID used by the DevHost manifest (Rootapp).
137
+ """
138
+
139
+ platform: RemotePlatform
140
+ token: str
141
+ chat_id: str | int
142
+ allowed_ids: set[str | int] = field(default_factory=set)
143
+ ping: bool = True
144
+ allowed_user_ids: set[str | int] = field(default_factory=set)
145
+ bot_id: str = ""
146
+
147
+ def __post_init__(self) -> None:
148
+ """Read shared-chat sender permissions from the existing remote config."""
149
+ if not self.allowed_user_ids:
150
+ from novacode_cli.remote.config import load_remote_config
151
+
152
+ values = load_remote_config().get(self.platform.value, {}).get("allowed_user_ids", [])
153
+ if isinstance(values, list):
154
+ self.allowed_user_ids = {
155
+ str(value) for value in values if isinstance(value, (str, int))
156
+ }
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Platform limits
161
+ # ---------------------------------------------------------------------------
162
+
163
+ MAX_MESSAGE_LENGTH: dict[RemotePlatform, int] = {
164
+ RemotePlatform.DISCORD: 2000,
165
+ RemotePlatform.TELEGRAM: 4096,
166
+ # Conservative page size; Root's SDK accepts Markdown content strings.
167
+ RemotePlatform.ROOTAPP: 2000,
168
+ }
169
+
170
+
171
+ def chunk_message(text: str, platform: RemotePlatform) -> list[str]:
172
+ """Split a long response into chunks that fit the platform's message limit.
173
+
174
+ Tries to break on paragraph boundaries first, then on sentence
175
+ boundaries, then on word boundaries. As a last resort, hard-breaks.
176
+
177
+ Args:
178
+ text: Full response text.
179
+ platform: Target platform.
180
+
181
+ Returns:
182
+ List of message chunks, each <= the platform's limit.
183
+ """
184
+ limit = MAX_MESSAGE_LENGTH.get(platform, 2000)
185
+ if len(text) <= limit:
186
+ return [text]
187
+
188
+ chunks: list[str] = []
189
+
190
+ # First try paragraph breaks
191
+ paragraphs = re.split(r"\n{2,}", text)
192
+ current = ""
193
+ for para in paragraphs:
194
+ candidate = (current + "\n\n" + para) if current else para
195
+ if len(candidate) <= limit:
196
+ current = candidate
197
+ else:
198
+ if current:
199
+ chunks.append(current)
200
+ # If a single paragraph exceeds the limit, split by sentences
201
+ if len(para) > limit:
202
+ chunks.extend(_split_by_sentences(para, limit))
203
+ current = ""
204
+ else:
205
+ current = para
206
+ if current:
207
+ chunks.append(current)
208
+
209
+ # Final pass: hard-break any chunks that still exceed the limit
210
+ # (e.g., a string with no spaces, sentences, or paragraphs)
211
+ result: list[str] = []
212
+ for chunk in chunks:
213
+ if len(chunk) <= limit:
214
+ result.append(chunk)
215
+ else:
216
+ # Hard-break at limit boundaries
217
+ for i in range(0, len(chunk), limit):
218
+ result.append(chunk[i : i + limit])
219
+
220
+ return result or [text[:limit]]
221
+
222
+
223
+ def format_tool_digest(tool_names: list[str], *, max_shown: int = 8) -> str:
224
+ """Condense a turn's tool activity into a single chat-friendly line.
225
+
226
+ Remote chats were being flooded with one message per tool call. Instead,
227
+ callers accumulate the tool names used during a turn and send the result of
228
+ this function ONCE, e.g.::
229
+
230
+ 🔧 12 tool calls · `read_file×4, grep×3, shell×2, write_file×2, task`
231
+
232
+ The tool list is wrapped in backticks so Telegram's ``parse_mode=Markdown``
233
+ treats underscores in names like ``read_file`` literally instead of as
234
+ italics (which would otherwise mangle or reject the message).
235
+
236
+ Args:
237
+ tool_names: Tool names in call order (duplicates expected; they're counted).
238
+ max_shown: Cap on distinct names listed before collapsing to "+N more".
239
+
240
+ Returns:
241
+ A single-line summary, or "" if no tools were used.
242
+ """
243
+ names = [n for n in tool_names if n]
244
+ if not names:
245
+ return ""
246
+
247
+ counts: dict[str, int] = {}
248
+ for n in names:
249
+ counts[n] = counts.get(n, 0) + 1 # insertion order = first-seen order
250
+
251
+ distinct = list(counts.items())
252
+ shown = distinct[:max_shown]
253
+ parts = [f"{name}×{c}" if c > 1 else name for name, c in shown]
254
+ body = ", ".join(parts)
255
+ extra = len(distinct) - len(shown)
256
+ if extra > 0:
257
+ body += f", +{extra} more"
258
+
259
+ total = len(names)
260
+ plural = "s" if total != 1 else ""
261
+ return f"🔧 {total} tool call{plural} · `{body}`"
262
+
263
+
264
+ # Tool name -> coarse activity category, for the compact end-of-turn footer.
265
+ _TOOL_CATEGORY: dict[str, str] = {
266
+ "read_file": "read",
267
+ "read": "read",
268
+ "view": "read",
269
+ "cat": "read",
270
+ "write_file": "edit",
271
+ "edit_file": "edit",
272
+ "write": "edit",
273
+ "edit": "edit",
274
+ "str_replace": "edit",
275
+ "execute": "run",
276
+ "run_tests": "run",
277
+ "shell": "run",
278
+ "bash": "run",
279
+ "run_command": "run",
280
+ "grep": "search",
281
+ "glob": "search",
282
+ "ls": "search",
283
+ "code_search": "search",
284
+ "find_related_code": "search",
285
+ "web_search": "web",
286
+ "duckduckgo_search": "web",
287
+ "fetch_url": "web",
288
+ "docs_search": "web",
289
+ "task": "subagent",
290
+ "think": "think",
291
+ }
292
+
293
+
294
+ def categorize_tools(tool_names: list[str]) -> str:
295
+ """Compact category counts for a turn's tools, e.g. ``read×4, edit×2, run×3``.
296
+
297
+ Collapses tools into coarse categories so progress reads as *what kind* of
298
+ work is happening, not a list of every tool. Returns "" when none.
299
+ """
300
+ names = [n for n in tool_names if n]
301
+ if not names:
302
+ return ""
303
+ counts: dict[str, int] = {}
304
+ for n in names:
305
+ cat = _TOOL_CATEGORY.get(n, "other")
306
+ counts[cat] = counts.get(cat, 0) + 1
307
+ return ", ".join(f"{cat}×{c}" if c > 1 else cat for cat, c in counts.items())
308
+
309
+
310
+ def format_activity_footer(tool_names: list[str]) -> str:
311
+ """One-line ``-#`` small-text footer, e.g. ``-# 🔧 11 tools · read×4, edit×2``."""
312
+ cats = categorize_tools(tool_names)
313
+ if not cats:
314
+ return ""
315
+ total = len([n for n in tool_names if n])
316
+ plural = "s" if total != 1 else ""
317
+ return f"-# 🔧 {total} tool{plural} · {cats}"
318
+
319
+
320
+ def _split_by_sentences(text: str, limit: int) -> list[str]:
321
+ """Split text into chunks on sentence boundaries."""
322
+ sentences = re.split(r"(?<=[.!?])\s+", text)
323
+ chunks: list[str] = []
324
+ current = ""
325
+ for s in sentences:
326
+ candidate = (current + " " + s) if current else s
327
+ if len(candidate) <= limit:
328
+ current = candidate
329
+ else:
330
+ if current:
331
+ chunks.append(current)
332
+ # If a single sentence exceeds limit, hard-split by words
333
+ if len(s) > limit:
334
+ words = s.split()
335
+ for w in words:
336
+ candidate2 = (current + " " + w) if current else w
337
+ if len(candidate2) <= limit:
338
+ current = candidate2
339
+ else:
340
+ if current:
341
+ chunks.append(current)
342
+ current = w
343
+ else:
344
+ current = s
345
+ if current:
346
+ chunks.append(current)
347
+
348
+ # Hard-break any oversized chunks
349
+ result: list[str] = []
350
+ for chunk in chunks:
351
+ if len(chunk) <= limit:
352
+ result.append(chunk)
353
+ else:
354
+ for i in range(0, len(chunk), limit):
355
+ result.append(chunk[i : i + limit])
356
+
357
+ return result or [text[:limit]]
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Bridge manager (singleton)
362
+ # ---------------------------------------------------------------------------
363
+
364
+
365
+ class RemoteBridgeManager:
366
+ """Manages active remote bridge instances.
367
+
368
+ Holds a reference to the message queue that the main CLI loop reads,
369
+ and manages the lifecycle of Discord/Telegram bot tasks.
370
+
371
+ Each bridge is stored as a dict with keys:
372
+ - "task": The asyncio.Task running the bridge
373
+ - "bridge": The bridge object (DiscordBridge, TelegramBridge, etc.)
374
+ - "config": The BridgeConfig
375
+ """
376
+
377
+ _WATCHDOG_INTERVAL = 30 # seconds between liveness checks
378
+ _MAX_RESTARTS = 5 # give up after this many consecutive failures per bridge
379
+
380
+ def __init__(self, message_queue: asyncio.Queue[RemoteMessage]) -> None:
381
+ self._queue = message_queue
382
+ self._bridges: dict[str, dict[str, Any]] = {} # bridge_id → {task, bridge, config}
383
+ self._running = False
384
+ self._on_status: Callable[[str], Awaitable[None]] | None = None
385
+ self._watchdog_task: asyncio.Task | None = None
386
+ self._restart_counts: dict[str, int] = {} # bridge_id → consecutive restart count
387
+
388
+ def set_status_callback(self, callback: Callable[[str], Awaitable[None]] | None) -> None:
389
+ """Set a callback for status messages from bridges.
390
+
391
+ The callback receives a status string and can display it to the user
392
+ (e.g., via the Rich console).
393
+ """
394
+ self._on_status = callback
395
+ for entry in self._bridges.values():
396
+ bridge = entry.get("bridge")
397
+ if hasattr(bridge, "_on_status"):
398
+ bridge._on_status = callback
399
+
400
+ async def _emit_status(self, msg: str) -> None:
401
+ """Emit a status message via callback."""
402
+ if self._on_status:
403
+ try:
404
+ await self._on_status(msg)
405
+ except Exception:
406
+ pass
407
+
408
+ @property
409
+ def is_running(self) -> bool:
410
+ return self._running
411
+
412
+ @property
413
+ def active_bridges(self) -> list[dict[str, Any]]:
414
+ """Return info about active bridges for display."""
415
+ info = []
416
+ for bid, entry in self._bridges.items():
417
+ task = entry.get("task")
418
+ bridge = entry.get("bridge")
419
+ config = entry.get("config")
420
+ is_alive = task is not None and not task.done()
421
+ status = "running" if is_alive else "stopped"
422
+
423
+ # Check bridge-specific diagnostics
424
+ last_error = None
425
+ bot_user = None
426
+ connected = False
427
+ if bridge is not None:
428
+ last_error = getattr(bridge, "last_error", None)
429
+ bot_user = getattr(bridge, "bot_user", None)
430
+ connected = getattr(bridge, "is_connected", False)
431
+
432
+ if not is_alive and last_error:
433
+ status = f"error: {last_error}"
434
+ elif is_alive and not connected:
435
+ status = "connecting..."
436
+
437
+ entry_info = {
438
+ "id": bid,
439
+ "platform": config.platform.value,
440
+ "chat_id": config.chat_id,
441
+ "status": status,
442
+ "bot_user": bot_user,
443
+ }
444
+ if config.platform == RemotePlatform.TELEGRAM and bridge is not None:
445
+ entry_info["session_topics"] = [
446
+ {"session_id": session_id, "topic_id": topic_id}
447
+ for session_id, topic_id in getattr(bridge, "_session_topics", {}).values()
448
+ ]
449
+ if config.platform == RemotePlatform.DISCORD and bridge is not None:
450
+ entry_info["session_threads"] = [
451
+ {"session_id": session_id, "thread_id": thread_id}
452
+ for session_id, thread_id in getattr(bridge, "_session_threads", {}).values()
453
+ ]
454
+ info.append(entry_info)
455
+ return info
456
+
457
+ def bridges(self, platform: RemotePlatform | None = None) -> list[Any]:
458
+ """Live bridge objects, optionally of one platform."""
459
+ return [
460
+ e["bridge"]
461
+ for e in self._bridges.values()
462
+ if e.get("bridge") is not None
463
+ and (platform is None or e["config"].platform == platform)
464
+ and e.get("task") is not None
465
+ and not e["task"].done()
466
+ ]
467
+
468
+ def _make_bridge_id(self, platform: RemotePlatform, chat_id: str | int) -> str:
469
+ return f"{platform.value}:{chat_id}"
470
+
471
+ async def stream_targets(self, *, sid: str = "root") -> list[RemoteMessage]:
472
+ """Fresh, independent output channels for local turns in configured chats."""
473
+ targets = []
474
+ for bridge in self.bridges():
475
+ if not bridge.is_connected:
476
+ continue
477
+ try:
478
+ target = await asyncio.wait_for(bridge.stream_target(sid=sid), timeout=5)
479
+ if target is not None:
480
+ targets.append(target)
481
+ except Exception:
482
+ logger.warning("Could not initialize remote streaming target", exc_info=True)
483
+ return targets
484
+
485
+ async def start_rootapp(self, token: str, channel_id: str, bot_id: str) -> tuple[bool, str]:
486
+ """Run Root's local SDK companion and await its authenticated handshake."""
487
+ from novacode_cli.remote.rootapp_bridge import RootappBridge
488
+
489
+ if not token or not channel_id or not bot_id:
490
+ return False, "Rootapp needs a Dev Token, channel ID and Bot ID."
491
+ bridge_id = self._make_bridge_id(RemotePlatform.ROOTAPP, channel_id)
492
+ if bridge_id in self._bridges:
493
+ await self.stop_bridge(bridge_id)
494
+ config = BridgeConfig(RemotePlatform.ROOTAPP, token, channel_id, bot_id=bot_id)
495
+ bridge = RootappBridge(config, self._queue, on_status=self._on_status)
496
+ task = asyncio.create_task(bridge.run(), name=f"rootapp-bridge-{bridge_id}")
497
+ self._bridges[bridge_id] = {"config": config, "bridge": bridge, "task": task}
498
+ self._running = True
499
+ try:
500
+ if await bridge.wait_for_ready():
501
+ self._ensure_watchdog()
502
+ return True, ""
503
+ error = bridge.last_error or "Rootapp startup timed out. Check the Dev Token, Bot ID and network."
504
+ except BaseException:
505
+ await self.stop_bridge(bridge_id)
506
+ raise
507
+ await self.stop_bridge(bridge_id)
508
+ return False, error
509
+
510
+ async def start_discord(
511
+ self, token: str, channel_id: str | int, ping: bool = True
512
+ ) -> tuple[bool, str]:
513
+ """Start a Discord bridge.
514
+
515
+ Args:
516
+ token: Discord bot token.
517
+ channel_id: Discord channel ID to listen on.
518
+ ping: Whether to ping/mention the user when the task is done.
519
+
520
+ Returns:
521
+ (success, error_message) tuple. error_message is empty on success.
522
+ """
523
+ bridge_id = self._make_bridge_id(RemotePlatform.DISCORD, channel_id)
524
+
525
+ if bridge_id in self._bridges:
526
+ existing_task = self._bridges[bridge_id].get("task")
527
+ if existing_task and not existing_task.done():
528
+ logger.warning(f"Discord bridge {bridge_id} already running")
529
+ return False, f"Bridge {bridge_id} is already running"
530
+
531
+ config = BridgeConfig(
532
+ platform=RemotePlatform.DISCORD,
533
+ token=token,
534
+ chat_id=str(channel_id),
535
+ allowed_ids={str(channel_id)},
536
+ ping=ping,
537
+ )
538
+
539
+ # Validate Discord token format (bot tokens: 3 dot-separated segments, ~70+ chars)
540
+ if token.count(".") != 2 or len(token) < 50:
541
+ return False, (
542
+ "Invalid Discord token format. "
543
+ "Bot tokens should be 3 dot-separated segments (e.g., NTIzNjU...). "
544
+ "Get one from https://discord.com/developers/applications"
545
+ )
546
+
547
+ try:
548
+ import discord # noqa: F401 -- eager check for availability
549
+
550
+ from novacode_cli.remote.discord_bridge import DiscordBridge
551
+
552
+ bridge = DiscordBridge(
553
+ config=config, message_queue=self._queue, on_status=self._on_status
554
+ )
555
+ task = asyncio.create_task(bridge.run(), name=f"discord-bridge-{bridge_id}")
556
+ self._bridges[bridge_id] = {
557
+ "task": task,
558
+ "bridge": bridge,
559
+ "config": config,
560
+ }
561
+ self._running = True
562
+ logger.info(f"Discord bridge task created: {bridge_id}")
563
+
564
+ # Wait for the gateway connection (up to 15 seconds)
565
+ connected = await bridge.wait_for_ready(timeout=15.0)
566
+ if connected:
567
+ logger.info(f"Discord bridge connected: {bridge_id} as {bridge.bot_user}")
568
+ logger.info(f"Bridge queue size: {self._queue.qsize()}")
569
+ self._ensure_watchdog()
570
+ return True, ""
571
+ else:
572
+ # Task started but not connected yet — check for immediate errors
573
+ if task.done():
574
+ exc = task.exception()
575
+ if exc:
576
+ error_msg = f"Discord bridge failed: {exc}"
577
+ logger.error(error_msg)
578
+ del self._bridges[bridge_id]
579
+ self._running = bool(self._bridges)
580
+ return False, bridge.last_error or error_msg
581
+ # Still connecting — report success but note pending connection
582
+ logger.info(f"Discord bridge still connecting: {bridge_id}")
583
+ self._ensure_watchdog()
584
+ return True, ""
585
+
586
+ except ImportError:
587
+ from novacode_cli.install_commands import missing_package
588
+
589
+ logger.error(missing_package("discord.py"))
590
+ return False, missing_package("discord.py")
591
+ except Exception as e:
592
+ logger.error(f"Failed to start Discord bridge: {e}")
593
+ return False, f"Failed to start Discord bridge: {e}"
594
+
595
+ async def start_telegram(self, token: str, chat_id: str | int) -> tuple[bool, str]:
596
+ """Start a Telegram bridge.
597
+
598
+ Token is verified against the Telegram API before the background
599
+ task is started. Invalid tokens return an error immediately.
600
+
601
+ Args:
602
+ token: Telegram bot token.
603
+ chat_id: Telegram chat ID to listen on.
604
+
605
+ Returns:
606
+ (success, error_message) tuple. error_message is empty on success.
607
+ """
608
+ bridge_id = self._make_bridge_id(RemotePlatform.TELEGRAM, chat_id)
609
+
610
+ if bridge_id in self._bridges:
611
+ existing_task = self._bridges[bridge_id].get("task")
612
+ if existing_task and not existing_task.done():
613
+ logger.warning(f"Telegram bridge {bridge_id} already running")
614
+ return False, f"Bridge {bridge_id} is already running"
615
+
616
+ config = BridgeConfig(
617
+ platform=RemotePlatform.TELEGRAM,
618
+ token=token,
619
+ chat_id=int(chat_id),
620
+ allowed_ids={int(chat_id)},
621
+ )
622
+
623
+ try:
624
+ import aiohttp
625
+
626
+ from novacode_cli.remote.telegram_bridge import TelegramBridge
627
+
628
+ # Verify the token works before creating the background task
629
+ async with aiohttp.ClientSession() as session:
630
+ url = f"https://api.telegram.org/bot{token}/getMe"
631
+ try:
632
+ async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
633
+ data = await resp.json()
634
+ if not data.get("ok"):
635
+ error_desc = data.get("description", "Unknown error")
636
+ return False, f"Telegram API error: {error_desc}"
637
+ bot_info = data.get("result", {})
638
+ bot_name = bot_info.get("username", "unknown")
639
+ except aiohttp.ClientError as e:
640
+ return False, f"Failed to connect to Telegram API: {e}"
641
+
642
+ bridge = TelegramBridge(config=config, message_queue=self._queue)
643
+ bridge._on_status = self._on_status
644
+ bridge.enable_shared_polling()
645
+ task = asyncio.create_task(bridge.run(), name=f"telegram-bridge-{bridge_id}")
646
+ self._bridges[bridge_id] = {
647
+ "task": task,
648
+ "bridge": bridge,
649
+ "config": config,
650
+ }
651
+ self._running = True
652
+ logger.info(f"Telegram bridge started: {bridge_id} (@{bot_name})")
653
+ self._ensure_watchdog()
654
+ return True, ""
655
+ except ImportError as e:
656
+ logger.error(f"Missing dependency for Telegram bridge: {e}")
657
+ return False, f"Missing dependency: {e}"
658
+ except Exception as e:
659
+ logger.error(f"Failed to start Telegram bridge: {e}")
660
+ return False, f"Failed to start Telegram bridge: {e}"
661
+
662
+ async def start_discord_auto_channel(
663
+ self, token: str, channel_name: str, guild_id: int | None = None, ping: bool = True
664
+ ) -> tuple[bool, str]:
665
+ """Start a Discord bridge and auto-create a channel.
666
+
667
+ Connects to Discord, waits for the gateway, creates a text channel
668
+ in the first guild, and starts listening on it.
669
+
670
+ Args:
671
+ token: Discord bot token.
672
+ channel_name: Name for the auto-created channel.
673
+ guild_id: Optional specific guild ID. If None, uses first guild.
674
+ ping: Whether to ping/mention the user when the task is done.
675
+
676
+ Returns:
677
+ (success, error_message) tuple. On success, the channel ID is
678
+ automatically saved to config.
679
+ """
680
+ # Step 1: Start the bridge without a channel spec
681
+ # We use a placeholder channel ID that we'll update after creating the channel
682
+ temp_channel_id = "0" # placeholder
683
+
684
+ bridge_id = self._make_bridge_id(RemotePlatform.DISCORD, temp_channel_id)
685
+
686
+ # Validate token format
687
+ if token.count(".") != 2 or len(token) < 50:
688
+ return False, (
689
+ "Invalid Discord token format. "
690
+ "Bot tokens should be 3 dot-separated segments (e.g., NTIzNjU...). "
691
+ "Get one from https://discord.com/developers/applications"
692
+ )
693
+
694
+ try:
695
+ import discord # noqa: F401
696
+
697
+ from novacode_cli.remote.discord_bridge import DiscordBridge
698
+
699
+ # Start with placeholder — we'll update after channel creation
700
+ config = BridgeConfig(
701
+ platform=RemotePlatform.DISCORD,
702
+ token=token,
703
+ chat_id=temp_channel_id,
704
+ allowed_ids={temp_channel_id},
705
+ ping=ping,
706
+ )
707
+
708
+ bridge = DiscordBridge(
709
+ config=config,
710
+ message_queue=self._queue,
711
+ on_status=self._on_status,
712
+ )
713
+ task = asyncio.create_task(bridge.run(), name=f"discord-bridge-auto")
714
+
715
+ # Wait for connection
716
+ connected = await bridge.wait_for_ready(timeout=15.0)
717
+ if not connected:
718
+ # Check for errors
719
+ if task.done() and task.exception():
720
+ error_msg = str(task.exception())
721
+ elif bridge.last_error:
722
+ error_msg = bridge.last_error
723
+ else:
724
+ error_msg = "Timed out waiting for Discord connection"
725
+ if not task.done():
726
+ task.cancel()
727
+ return False, error_msg
728
+
729
+ # Step 2: Create the channel
730
+ success, channel_id, error_msg = await bridge.create_channel(
731
+ channel_name, guild_id=guild_id
732
+ )
733
+ if not success:
734
+ # Stop the bridge since we can't proceed
735
+ if not task.done():
736
+ task.cancel()
737
+ return False, error_msg
738
+
739
+ # Step 3: Update the config with the real channel ID
740
+ config.chat_id = channel_id
741
+ config.allowed_ids = {channel_id}
742
+ bridge._config = config # update the bridge's config too
743
+
744
+ # Remove the placeholder and store with real ID
745
+ if bridge_id in self._bridges:
746
+ del self._bridges[bridge_id]
747
+
748
+ real_bridge_id = self._make_bridge_id(RemotePlatform.DISCORD, channel_id)
749
+ self._bridges[real_bridge_id] = {
750
+ "task": task,
751
+ "bridge": bridge,
752
+ "config": config,
753
+ }
754
+ self._running = True
755
+
756
+ logger.info(f"Discord bridge started with auto-created channel: {channel_id}")
757
+ return True, f"Channel #{channel_name} created (ID: {channel_id})"
758
+
759
+ except ImportError:
760
+ from novacode_cli.install_commands import missing_package
761
+
762
+ return False, missing_package("discord.py")
763
+ except Exception as e:
764
+ logger.error(f"Failed to start Discord bridge with auto channel: {e}")
765
+ return False, f"Failed to start Discord bridge: {e}"
766
+
767
+ async def stop_bridge(self, bridge_id: str | None = None) -> None:
768
+ """Stop a specific bridge or all bridges.
769
+
770
+ Args:
771
+ bridge_id: If given, stop this specific bridge. Otherwise stop all.
772
+ """
773
+ if bridge_id:
774
+ entry = self._bridges.pop(bridge_id, None)
775
+ if entry:
776
+ task = entry.get("task")
777
+ bridge = entry.get("bridge")
778
+ if task and not task.done():
779
+ task.cancel()
780
+ try:
781
+ await task
782
+ except asyncio.CancelledError:
783
+ pass
784
+ if bridge and hasattr(bridge, "stop"):
785
+ await bridge.stop()
786
+ else:
787
+ for bid, entry in list(self._bridges.items()):
788
+ task = entry.get("task")
789
+ bridge = entry.get("bridge")
790
+ if task and not task.done():
791
+ task.cancel()
792
+ try:
793
+ await task
794
+ except asyncio.CancelledError:
795
+ pass
796
+ if bridge and hasattr(bridge, "stop"):
797
+ await bridge.stop()
798
+ self._bridges.clear()
799
+ self._running = any(
800
+ entry.get("task") and not entry["task"].done() for entry in self._bridges.values()
801
+ )
802
+ if not self._running and self._watchdog_task and not self._watchdog_task.done():
803
+ self._watchdog_task.cancel()
804
+
805
+ async def _watchdog(self) -> None:
806
+ """Periodically check that bridge tasks are alive and restart crashed ones.
807
+
808
+ Runs as a long-lived asyncio Task while at least one bridge is registered.
809
+ Respects ``_MAX_RESTARTS`` so permanently broken bridges don't loop forever.
810
+ """
811
+ try:
812
+ while True:
813
+ await asyncio.sleep(self._WATCHDOG_INTERVAL)
814
+
815
+ for bridge_id, entry in list(self._bridges.items()):
816
+ task: asyncio.Task | None = entry.get("task")
817
+ config: BridgeConfig | None = entry.get("config")
818
+ if task is None or config is None:
819
+ continue
820
+ if not task.done():
821
+ # Still alive — reset the failure counter.
822
+ self._restart_counts.pop(bridge_id, None)
823
+ continue
824
+
825
+ count = self._restart_counts.get(bridge_id, 0)
826
+ if count >= self._MAX_RESTARTS:
827
+ logger.warning(
828
+ "Bridge %s exceeded restart limit (%d) — giving up",
829
+ bridge_id,
830
+ self._MAX_RESTARTS,
831
+ )
832
+ continue
833
+
834
+ logger.warning("Bridge %s died, restarting (attempt %d)", bridge_id, count + 1)
835
+ self._restart_counts[bridge_id] = count + 1
836
+
837
+ try:
838
+ if config.platform == RemotePlatform.DISCORD:
839
+ from novacode_cli.remote.discord_bridge import DiscordBridge
840
+
841
+ bridge = DiscordBridge(
842
+ config=config,
843
+ message_queue=self._queue,
844
+ on_status=self._on_status,
845
+ )
846
+ previous = entry.get("bridge")
847
+ bridge._session_threads = dict(getattr(previous, "_session_threads", {}))
848
+ bridge._session_thread_names = dict(getattr(previous, "_session_thread_names", {}))
849
+ bridge._outgoing_owners.update(getattr(previous, "_outgoing_owners", {}))
850
+ new_task = asyncio.create_task(
851
+ bridge.run(), name=f"discord-bridge-{bridge_id}"
852
+ )
853
+ elif config.platform == RemotePlatform.ROOTAPP:
854
+ from novacode_cli.remote.rootapp_bridge import RootappBridge
855
+
856
+ bridge = RootappBridge(config, self._queue, on_status=self._on_status)
857
+ new_task = asyncio.create_task(bridge.run(), name=f"rootapp-bridge-{bridge_id}")
858
+ else:
859
+ from novacode_cli.remote.telegram_bridge import TelegramBridge
860
+
861
+ bridge = TelegramBridge(config=config, message_queue=self._queue)
862
+ bridge._on_status = self._on_status
863
+ previous = entry.get("bridge")
864
+ bridge._topic_handlers = dict(getattr(previous, "_topic_handlers", {}))
865
+ bridge.enable_shared_polling()
866
+ new_task = asyncio.create_task(
867
+ bridge.run(), name=f"telegram-bridge-{bridge_id}"
868
+ )
869
+
870
+ entry["bridge"] = bridge
871
+ entry["task"] = new_task
872
+ await self._emit_status(
873
+ f"🔄 Restarted {config.platform.value} bridge "
874
+ f"(attempt {count + 1}/{self._MAX_RESTARTS})"
875
+ )
876
+ except Exception as exc: # noqa: BLE001
877
+ logger.error("Failed to restart bridge %s: %s", bridge_id, exc)
878
+
879
+ except asyncio.CancelledError:
880
+ pass
881
+
882
+ def _ensure_watchdog(self) -> None:
883
+ """Start the watchdog task if it isn't running."""
884
+ if self._watchdog_task is None or self._watchdog_task.done():
885
+ self._watchdog_task = asyncio.create_task(self._watchdog(), name="bridge-watchdog")
886
+
887
+ async def stop_all(self) -> None:
888
+ """Stop all bridges gracefully."""
889
+ await self.stop_bridge(None)
890
+
891
+ def get_bridge(self, bridge_id: str) -> dict[str, Any] | None:
892
+ """Get info about a specific bridge."""
893
+ if bridge_id in self._bridges:
894
+ entry = self._bridges[bridge_id]
895
+ task = entry.get("task")
896
+ bridge = entry.get("bridge")
897
+ config = entry.get("config")
898
+ return {
899
+ "id": bridge_id,
900
+ "config": config,
901
+ "status": "running" if task and not task.done() else "stopped",
902
+ "bot_user": getattr(bridge, "bot_user", None) if bridge else None,
903
+ "last_error": getattr(bridge, "last_error", None) if bridge else None,
904
+ }
905
+ return None