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.
- novacode_cli/__init__.py +29 -0
- novacode_cli/__main__.py +6 -0
- novacode_cli/_lazy_heavy.py +99 -0
- novacode_cli/_version.py +3 -0
- novacode_cli/agent_stream.py +178 -0
- novacode_cli/agents/agent_file.py +250 -0
- novacode_cli/agents/agent_mailbox.py +120 -0
- novacode_cli/agents/approval_decisions.py +146 -0
- novacode_cli/agents/arg_repair.py +196 -0
- novacode_cli/agents/async_agents/__init__.py +6 -0
- novacode_cli/agents/async_agents/_config.py +213 -0
- novacode_cli/agents/async_agents/_mcp_tools.py +192 -0
- novacode_cli/agents/async_agents/_specialist.py +84 -0
- novacode_cli/agents/async_agents/_test_commands.py +89 -0
- novacode_cli/agents/async_agents/_tools.py +432 -0
- novacode_cli/agents/async_agents/general_purpose.py +42 -0
- novacode_cli/agents/async_agents/langgraph.json +9 -0
- novacode_cli/agents/async_agents/research.py +22 -0
- novacode_cli/agents/async_context.py +303 -0
- novacode_cli/agents/async_on_demand.py +52 -0
- novacode_cli/agents/async_task_errors.py +75 -0
- novacode_cli/agents/async_workspace.py +27 -0
- novacode_cli/agents/attribution.py +165 -0
- novacode_cli/agents/browser_mode.py +119 -0
- novacode_cli/agents/btw_agent.py +71 -0
- novacode_cli/agents/caveman.py +352 -0
- novacode_cli/agents/caveman_support.py +370 -0
- novacode_cli/agents/code_mode.py +774 -0
- novacode_cli/agents/code_runtime.py +160 -0
- novacode_cli/agents/context_compaction.py +1339 -0
- novacode_cli/agents/core_agent.py +2710 -0
- novacode_cli/agents/default_subagents/__init__.py +24 -0
- novacode_cli/agents/default_subagents/async_subagents.py +296 -0
- novacode_cli/agents/default_subagents/builtin/browser-automation-agent.md +110 -0
- novacode_cli/agents/default_subagents/builtin/bug-fix-agent.md +22 -0
- novacode_cli/agents/default_subagents/builtin/bug-investigation-agent.md +42 -0
- novacode_cli/agents/default_subagents/builtin/code-explorer.md +37 -0
- novacode_cli/agents/default_subagents/builtin/code-review-agent.md +33 -0
- novacode_cli/agents/default_subagents/builtin/dependency-audit-agent.md +34 -0
- novacode_cli/agents/default_subagents/builtin/documentation-update-agent.md +46 -0
- novacode_cli/agents/default_subagents/builtin/performance-audit-agent.md +41 -0
- novacode_cli/agents/default_subagents/builtin/plan-scout-agent.md +64 -0
- novacode_cli/agents/default_subagents/builtin/refactoring-agent.md +35 -0
- novacode_cli/agents/default_subagents/builtin/refactoring-specialist-agent.md +175 -0
- novacode_cli/agents/default_subagents/builtin/security-audit-agent.md +96 -0
- novacode_cli/agents/default_subagents/builtin/test-generation-agent.md +33 -0
- novacode_cli/agents/default_subagents/builtin/test-runner-agent.md +55 -0
- novacode_cli/agents/default_subagents/prompt.py +88 -0
- novacode_cli/agents/default_subagents/subagents.py +138 -0
- novacode_cli/agents/dream_agent.py +93 -0
- novacode_cli/agents/edit_diagnostics.py +362 -0
- novacode_cli/agents/edit_fallback.py +62 -0
- novacode_cli/agents/handoff.py +166 -0
- novacode_cli/agents/invocation.py +152 -0
- novacode_cli/agents/model_budget.py +42 -0
- novacode_cli/agents/model_retry.py +113 -0
- novacode_cli/agents/model_router.py +420 -0
- novacode_cli/agents/openai_decisions.py +186 -0
- novacode_cli/agents/plan_agent/__init__.py +12 -0
- novacode_cli/agents/plan_agent/plan_agent.py +357 -0
- novacode_cli/agents/plan_agent/plan_mode_middleware.py +64 -0
- novacode_cli/agents/plan_execution.py +237 -0
- novacode_cli/agents/ponytail.py +66 -0
- novacode_cli/agents/prime.py +274 -0
- novacode_cli/agents/provider_caching.py +99 -0
- novacode_cli/agents/server_launcher.py +904 -0
- novacode_cli/agents/strict_tools.py +384 -0
- novacode_cli/agents/task_discipline.py +294 -0
- novacode_cli/agents/tool_call_repair.py +290 -0
- novacode_cli/agents/tool_offload.py +291 -0
- novacode_cli/agents/tool_search.py +806 -0
- novacode_cli/agents/tool_search_variants.py +106 -0
- novacode_cli/agents/tool_verdicts.py +1194 -0
- novacode_cli/agents/user_async_agents.py +312 -0
- novacode_cli/artifacts/__init__.py +10 -0
- novacode_cli/artifacts/registry.py +278 -0
- novacode_cli/artifacts/server.py +426 -0
- novacode_cli/assets/Nova.png +0 -0
- novacode_cli/assets/nova-avatar.png +0 -0
- novacode_cli/audio/__init__.py +301 -0
- novacode_cli/audio/capture.py +137 -0
- novacode_cli/audio/downloads.py +273 -0
- novacode_cli/audio/pipeline.py +391 -0
- novacode_cli/audio/playback.py +87 -0
- novacode_cli/audio/providers.py +208 -0
- novacode_cli/audio/speakable.py +78 -0
- novacode_cli/audio/stt.py +132 -0
- novacode_cli/audio/stt_deepgram.py +69 -0
- novacode_cli/audio/stt_elevenlabs.py +89 -0
- novacode_cli/audio/stt_parakeet.py +94 -0
- novacode_cli/audio/stt_whistle.py +61 -0
- novacode_cli/audio/summarize.py +92 -0
- novacode_cli/audio/tts.py +85 -0
- novacode_cli/audio/tts_kokoro.py +73 -0
- novacode_cli/audio/tts_orpheus.py +180 -0
- novacode_cli/audio/tts_pocket.py +131 -0
- novacode_cli/audio/tts_sano.py +69 -0
- novacode_cli/audio/vad.py +145 -0
- novacode_cli/backends/__init__.py +17 -0
- novacode_cli/backends/filesystem.py +894 -0
- novacode_cli/backends/native_search.py +92 -0
- novacode_cli/bootstrap/__init__.py +9 -0
- novacode_cli/bootstrap/env_snapshot.py +240 -0
- novacode_cli/bootstrap/steering.py +464 -0
- novacode_cli/bootstrap/vision_router.py +604 -0
- novacode_cli/brand.py +362 -0
- novacode_cli/cli_args.py +718 -0
- novacode_cli/commands/__init__.py +237 -0
- novacode_cli/commands/agents_commands.py +231 -0
- novacode_cli/commands/auth_handler.py +40 -0
- novacode_cli/commands/browser_use_handler.py +75 -0
- novacode_cli/commands/cache_handler.py +32 -0
- novacode_cli/commands/chat_handler.py +1357 -0
- novacode_cli/commands/ci_fix.py +171 -0
- novacode_cli/commands/claude_plan.py +331 -0
- novacode_cli/commands/codex_plan.py +195 -0
- novacode_cli/commands/commands.py +1113 -0
- novacode_cli/commands/create_handler.py +124 -0
- novacode_cli/commands/create_server.py +1887 -0
- novacode_cli/commands/cron_handler.py +119 -0
- novacode_cli/commands/dream_handler.py +203 -0
- novacode_cli/commands/effort_handler.py +65 -0
- novacode_cli/commands/evolution_handler.py +214 -0
- novacode_cli/commands/file_commands.py +381 -0
- novacode_cli/commands/handoff_handler.py +68 -0
- novacode_cli/commands/hooks_handler.py +269 -0
- novacode_cli/commands/init_handler.py +146 -0
- novacode_cli/commands/learning_handler.py +160 -0
- novacode_cli/commands/lite_mode_handler.py +76 -0
- novacode_cli/commands/log_commands.py +363 -0
- novacode_cli/commands/mcp_handler.py +29 -0
- novacode_cli/commands/model_handler.py +29 -0
- novacode_cli/commands/notifications_handler.py +158 -0
- novacode_cli/commands/onboard_handler.py +121 -0
- novacode_cli/commands/plan_handler.py +440 -0
- novacode_cli/commands/planner_usage.py +250 -0
- novacode_cli/commands/plugin_install_handler.py +38 -0
- novacode_cli/commands/plugins_handler.py +35 -0
- novacode_cli/commands/prime_handler.py +455 -0
- novacode_cli/commands/prompt_handler.py +149 -0
- novacode_cli/commands/provider_usage.py +160 -0
- novacode_cli/commands/ralph_events.py +106 -0
- novacode_cli/commands/ralph_handler.py +1293 -0
- novacode_cli/commands/refine_handler.py +213 -0
- novacode_cli/commands/research_handler.py +202 -0
- novacode_cli/commands/rootapp_remote.py +83 -0
- novacode_cli/commands/router_handler.py +36 -0
- novacode_cli/commands/server_commands.py +249 -0
- novacode_cli/commands/session_commands.py +203 -0
- novacode_cli/commands/share_handler.py +450 -0
- novacode_cli/commands/side_commands.py +366 -0
- novacode_cli/commands/skill_invoke.py +420 -0
- novacode_cli/commands/skills_commands.py +48 -0
- novacode_cli/commands/trace_handler.py +322 -0
- novacode_cli/commands/trello_handler.py +226 -0
- novacode_cli/commands/trello_remote.py +177 -0
- novacode_cli/commands/trello_server.py +1214 -0
- novacode_cli/commands/vision_handler.py +127 -0
- novacode_cli/commands/voice_handler.py +447 -0
- novacode_cli/commands/webhook_handler.py +134 -0
- novacode_cli/commands/wiki_commands.py +227 -0
- novacode_cli/compaction.py +753 -0
- novacode_cli/computation_cache.py +159 -0
- novacode_cli/computer/__init__.py +1 -0
- novacode_cli/computer/persistent.py +234 -0
- novacode_cli/computer/preview.py +253 -0
- novacode_cli/computer/runtime.py +474 -0
- novacode_cli/computer/windows_console.py +152 -0
- novacode_cli/computer/windows_qemu.py +211 -0
- novacode_cli/config/browser_mode.py +159 -0
- novacode_cli/config/catalog_models.py +95 -0
- novacode_cli/config/config.py +1388 -0
- novacode_cli/config/credentials.py +540 -0
- novacode_cli/config/google_oauth_auth.py +281 -0
- novacode_cli/config/model_capabilities.py +343 -0
- novacode_cli/config/model_catalog.py +275 -0
- novacode_cli/config/model_create.py +1325 -0
- novacode_cli/config/model_manager.py +695 -0
- novacode_cli/config/nova_config.py +1283 -0
- novacode_cli/config/openai_chatgpt_auth.py +384 -0
- novacode_cli/config/opencode_gateway.py +25 -0
- novacode_cli/config/plan_mode.py +188 -0
- novacode_cli/config/provider_auth.py +438 -0
- novacode_cli/config/provider_catalog.json +5127 -0
- novacode_cli/config/provider_registry.py +38 -0
- novacode_cli/config/provider_transports.py +258 -0
- novacode_cli/config/role_models.py +175 -0
- novacode_cli/context/__init__.py +50 -0
- novacode_cli/context/_analysis.py +799 -0
- novacode_cli/context/_budget.py +116 -0
- novacode_cli/context/_conversation.py +73 -0
- novacode_cli/context/_dynamic.py +264 -0
- novacode_cli/context/_model_config.py +380 -0
- novacode_cli/context/_models_dev.py +99 -0
- novacode_cli/context/archives.py +248 -0
- novacode_cli/context/estimates.py +65 -0
- novacode_cli/context/history.py +17 -0
- novacode_cli/context/knowledge_cache.py +292 -0
- novacode_cli/context/manager.py +101 -0
- novacode_cli/context/observations.py +65 -0
- novacode_cli/context/pressure.py +168 -0
- novacode_cli/context/provider_accounting.py +109 -0
- novacode_cli/context/snapshot.py +493 -0
- novacode_cli/context/strategies.py +279 -0
- novacode_cli/context/task_progress.py +122 -0
- novacode_cli/core/__init__.py +0 -0
- novacode_cli/core/agent_loop.py +1821 -0
- novacode_cli/core/autonomous_loop.py +277 -0
- novacode_cli/core/code_approvals.py +57 -0
- novacode_cli/core/input_preparation.py +276 -0
- novacode_cli/core/reasoning.py +67 -0
- novacode_cli/core/streaming.py +326 -0
- novacode_cli/core/subagent_tasks.py +696 -0
- novacode_cli/core/subagent_tracking.py +235 -0
- novacode_cli/core/tool_results.py +116 -0
- novacode_cli/core/verification_loop.py +272 -0
- novacode_cli/council.py +595 -0
- novacode_cli/council_consensus.py +115 -0
- novacode_cli/council_decision.py +157 -0
- novacode_cli/council_planner.py +1421 -0
- novacode_cli/council_settings.py +208 -0
- novacode_cli/council_web_settings.py +41 -0
- novacode_cli/council_workspace.py +56 -0
- novacode_cli/cowork/__init__.py +10 -0
- novacode_cli/cowork/broker_middleware.py +162 -0
- novacode_cli/cowork/launcher.py +102 -0
- novacode_cli/cowork/policy.py +284 -0
- novacode_cli/cowork/ui.py +826 -0
- novacode_cli/daemons/__init__.py +1 -0
- novacode_cli/daemons/registry.py +342 -0
- novacode_cli/desktop_notifications.py +302 -0
- novacode_cli/doctor.py +251 -0
- novacode_cli/embeddings.py +109 -0
- novacode_cli/entrypoint.py +56 -0
- novacode_cli/errors/__init__.py +19 -0
- novacode_cli/errors/handlers.py +648 -0
- novacode_cli/errors/provider_errors.py +249 -0
- novacode_cli/errors/taxonomy.py +80 -0
- novacode_cli/events.py +179 -0
- novacode_cli/file_ops.py +714 -0
- novacode_cli/git_safety.py +281 -0
- novacode_cli/handoff.py +383 -0
- novacode_cli/headless/__init__.py +18 -0
- novacode_cli/headless/input.py +58 -0
- novacode_cli/headless/output.py +224 -0
- novacode_cli/headless/pipe.py +532 -0
- novacode_cli/headless/runner.py +461 -0
- novacode_cli/hermes/__init__.py +39 -0
- novacode_cli/hermes/activation.py +297 -0
- novacode_cli/hermes/config.py +129 -0
- novacode_cli/hermes/curator.py +199 -0
- novacode_cli/hermes/evolution.py +374 -0
- novacode_cli/hermes/ledger.py +364 -0
- novacode_cli/hermes/memory_tiers.py +969 -0
- novacode_cli/hermes/middleware.py +678 -0
- novacode_cli/hermes/overview.py +174 -0
- novacode_cli/hermes/prompt_evolution.py +503 -0
- novacode_cli/hermes/refine_loop.py +643 -0
- novacode_cli/hermes/refinement_log.py +190 -0
- novacode_cli/hermes/review.py +674 -0
- novacode_cli/hermes/skill_debate.py +135 -0
- novacode_cli/hermes/skill_discovery.py +450 -0
- novacode_cli/hermes/skill_manager.py +335 -0
- novacode_cli/hermes/skill_proposals.py +43 -0
- novacode_cli/hermes/test_gaming.py +119 -0
- novacode_cli/hermes/tracker.py +435 -0
- novacode_cli/hermes/tuner.py +206 -0
- novacode_cli/hermes/verification_memory.py +134 -0
- novacode_cli/hermes/verifier.py +277 -0
- novacode_cli/hitl/__init__.py +22 -0
- novacode_cli/hitl/interrupts.py +493 -0
- novacode_cli/hooks.py +359 -0
- novacode_cli/image_utils.py +517 -0
- novacode_cli/init/__init__.py +7 -0
- novacode_cli/init/events.py +43 -0
- novacode_cli/input_utils.py +526 -0
- novacode_cli/install_commands.py +87 -0
- novacode_cli/integrations/__init__.py +82 -0
- novacode_cli/integrations/daytona.py +122 -0
- novacode_cli/integrations/docker.py +309 -0
- novacode_cli/integrations/host_path.py +111 -0
- novacode_cli/integrations/langsmith.py +151 -0
- novacode_cli/integrations/modal.py +134 -0
- novacode_cli/integrations/runloop.py +130 -0
- novacode_cli/integrations/sandbox_factory.py +1388 -0
- novacode_cli/integrations/sandbox_registry.py +312 -0
- novacode_cli/integrations/workdir_backend.py +589 -0
- novacode_cli/main.py +2078 -0
- novacode_cli/mcp/__init__.py +73 -0
- novacode_cli/mcp/auth.py +189 -0
- novacode_cli/mcp/client.py +571 -0
- novacode_cli/mcp/code_mode.py +5 -0
- novacode_cli/mcp/code_runtime.py +5 -0
- novacode_cli/mcp/commands.py +902 -0
- novacode_cli/mcp/config.py +406 -0
- novacode_cli/mcp/generic.py +128 -0
- novacode_cli/mcp/middleware.py +993 -0
- novacode_cli/mcp/presets.py +367 -0
- novacode_cli/memory/__init__.py +1 -0
- novacode_cli/memory/agent_memory.py +1312 -0
- novacode_cli/memory/builtin_lessons.py +60 -0
- novacode_cli/memory/dream_gates.py +192 -0
- novacode_cli/memory/limits.py +59 -0
- novacode_cli/memory/parsed_cache.py +106 -0
- novacode_cli/memory/recall_stats.py +97 -0
- novacode_cli/memory/semantic.py +116 -0
- novacode_cli/memory/store.py +335 -0
- novacode_cli/memory/wiki.py +274 -0
- novacode_cli/migrate.py +185 -0
- novacode_cli/onboarding.py +649 -0
- novacode_cli/onboarding_check.py +119 -0
- novacode_cli/path_approval.py +297 -0
- novacode_cli/plan_archive.py +148 -0
- novacode_cli/plugins/__init__.py +121 -0
- novacode_cli/plugins/claude_plugins.py +538 -0
- novacode_cli/plugins/cli.py +168 -0
- novacode_cli/plugins/components.py +413 -0
- novacode_cli/plugins/hooks.py +356 -0
- novacode_cli/plugins/loader.py +317 -0
- novacode_cli/plugins/marketplaces.py +199 -0
- novacode_cli/plugins/storage.py +70 -0
- novacode_cli/prime.py +759 -0
- novacode_cli/prime_factory/__init__.py +15 -0
- novacode_cli/prime_factory/container.py +198 -0
- novacode_cli/prime_factory/controller.py +565 -0
- novacode_cli/prime_factory/knowledge.py +261 -0
- novacode_cli/prime_factory/publication.py +218 -0
- novacode_cli/prime_factory/recovery.py +50 -0
- novacode_cli/prime_factory/research.py +370 -0
- novacode_cli/prime_factory/runner.py +608 -0
- novacode_cli/prime_factory/runtime.py +261 -0
- novacode_cli/prime_factory/state.py +201 -0
- novacode_cli/prime_factory/store.py +128 -0
- novacode_cli/prime_factory/worker.py +129 -0
- novacode_cli/prime_factory/workflow.py +899 -0
- novacode_cli/prime_factory/workspace.py +317 -0
- novacode_cli/prime_memory.py +290 -0
- novacode_cli/process_manager.py +585 -0
- novacode_cli/project_onboard.py +537 -0
- novacode_cli/prompts/System_Prompt_Nova.jinja +23 -0
- novacode_cli/prompts/__init__.py +168 -0
- novacode_cli/prompts/agent_definitions.jinja +11 -0
- novacode_cli/prompts/agent_generation.jinja +125 -0
- novacode_cli/prompts/async_agents/general_purpose.jinja +22 -0
- novacode_cli/prompts/claude_plan_mode.jinja +12 -0
- novacode_cli/prompts/coding_workflow.jinja +18 -0
- novacode_cli/prompts/computer_use.jinja +17 -0
- novacode_cli/prompts/core_agent_system.jinja +81 -0
- novacode_cli/prompts/delegation.jinja +15 -0
- novacode_cli/prompts/desktop_power.jinja +4 -0
- novacode_cli/prompts/file_paths.jinja +15 -0
- novacode_cli/prompts/file_tracker.jinja +23 -0
- novacode_cli/prompts/init_agents_md.jinja +56 -0
- novacode_cli/prompts/init_exploration.jinja +174 -0
- novacode_cli/prompts/init_semantic_orchestrate.jinja +37 -0
- novacode_cli/prompts/instruction_scope.jinja +5 -0
- novacode_cli/prompts/learnings_extraction.jinja +45 -0
- novacode_cli/prompts/longterm_memory.jinja +54 -0
- novacode_cli/prompts/mcp.jinja +84 -0
- novacode_cli/prompts/memory_consolidation.jinja +40 -0
- novacode_cli/prompts/nova_evolution.jinja +73 -0
- novacode_cli/prompts/nova_review.jinja +158 -0
- novacode_cli/prompts/nova_skill_debate.jinja +36 -0
- novacode_cli/prompts/nova_standing_instructions.jinja +26 -0
- novacode_cli/prompts/nova_verify.jinja +100 -0
- novacode_cli/prompts/onboard_dashboard.jinja +378 -0
- novacode_cli/prompts/plan_agent.jinja +87 -0
- novacode_cli/prompts/plan_scouts.jinja +5 -0
- novacode_cli/prompts/plan_storage.jinja +8 -0
- novacode_cli/prompts/planning.jinja +82 -0
- novacode_cli/prompts/ponytail/LICENSE +21 -0
- novacode_cli/prompts/ponytail/UPSTREAM.json +15 -0
- novacode_cli/prompts/ponytail/hooks/ponytail-config.js +175 -0
- novacode_cli/prompts/ponytail/hooks/ponytail-instructions.js +98 -0
- novacode_cli/prompts/ponytail/skills/ponytail/SKILL.md +52 -0
- novacode_cli/prompts/ponytail/skills/ponytail-audit/SKILL.md +103 -0
- novacode_cli/prompts/ponytail/skills/ponytail-debt/SKILL.md +44 -0
- novacode_cli/prompts/ponytail/skills/ponytail-gain/SKILL.md +52 -0
- novacode_cli/prompts/ponytail/skills/ponytail-help/SKILL.md +76 -0
- novacode_cli/prompts/ponytail/skills/ponytail-review/SKILL.md +101 -0
- novacode_cli/prompts/prompt_evolution.jinja +27 -0
- novacode_cli/prompts/ralph_iteration.jinja +75 -0
- novacode_cli/prompts/refine_gate.jinja +31 -0
- novacode_cli/prompts/refine_plan.jinja +64 -0
- novacode_cli/prompts/refine_review.jinja +34 -0
- novacode_cli/prompts/research_delivery.jinja +9 -0
- novacode_cli/prompts/research_swarm.jinja +130 -0
- novacode_cli/prompts/response_style.jinja +5 -0
- novacode_cli/prompts/session_summarization.jinja +86 -0
- novacode_cli/prompts/skill_creation.jinja +85 -0
- novacode_cli/prompts/subagents/fact_checker.jinja +48 -0
- novacode_cli/prompts/subagents/financial_analyst.jinja +50 -0
- novacode_cli/prompts/subagents/literature_reviewer.jinja +52 -0
- novacode_cli/prompts/subagents/market_analyst.jinja +48 -0
- novacode_cli/prompts/subagents/research_synthesizer.jinja +57 -0
- novacode_cli/prompts/subagents/technical_researcher.jinja +56 -0
- novacode_cli/prompts/subagents/web_researcher.jinja +46 -0
- novacode_cli/prompts/summarization.jinja +107 -0
- novacode_cli/prompts/summarization_answers.jinja +23 -0
- novacode_cli/prompts/summarization_questions.jinja +32 -0
- novacode_cli/prompts/vision_caption.jinja +21 -0
- novacode_cli/prompts/voice_summary.jinja +65 -0
- novacode_cli/prompts/wiki_file.jinja +57 -0
- novacode_cli/prompts/wiki_ingest.jinja +87 -0
- novacode_cli/recovery.py +383 -0
- novacode_cli/remote/__init__.py +45 -0
- novacode_cli/remote/approvals.py +282 -0
- novacode_cli/remote/async_task_clients.py +97 -0
- novacode_cli/remote/async_task_watcher.py +240 -0
- novacode_cli/remote/bridge.py +905 -0
- novacode_cli/remote/command_menu.py +26 -0
- novacode_cli/remote/command_ui.py +243 -0
- novacode_cli/remote/config.py +199 -0
- novacode_cli/remote/discord_bridge.py +530 -0
- novacode_cli/remote/discord_format.py +31 -0
- novacode_cli/remote/discord_threads.py +225 -0
- novacode_cli/remote/images.py +97 -0
- novacode_cli/remote/processor.py +601 -0
- novacode_cli/remote/rootapp_bot/bot.cjs +16 -0
- novacode_cli/remote/rootapp_bot/package-lock.json +2683 -0
- novacode_cli/remote/rootapp_bot/package.json +18 -0
- novacode_cli/remote/rootapp_bot/transport.cjs +80 -0
- novacode_cli/remote/rootapp_bridge.py +370 -0
- novacode_cli/remote/rootapp_setup.py +89 -0
- novacode_cli/remote/routing.py +103 -0
- novacode_cli/remote/scheduler.py +302 -0
- novacode_cli/remote/session.py +164 -0
- novacode_cli/remote/status.py +411 -0
- novacode_cli/remote/streaming.py +188 -0
- novacode_cli/remote/telegram_bridge.py +1118 -0
- novacode_cli/remote/telegram_format.py +215 -0
- novacode_cli/remote/telegram_hub.py +256 -0
- novacode_cli/remote/voice_notes.py +120 -0
- novacode_cli/remote/webhook_adapters.py +167 -0
- novacode_cli/remote/webhook_server.py +154 -0
- novacode_cli/rewind.py +150 -0
- novacode_cli/rooms/__init__.py +1 -0
- novacode_cli/rooms/bridge.py +86 -0
- novacode_cli/rooms/cli.py +231 -0
- novacode_cli/rooms/nova.py +227 -0
- novacode_cli/rooms/runtime.py +299 -0
- novacode_cli/rooms/store.py +538 -0
- novacode_cli/rooms/workspace.py +94 -0
- novacode_cli/security/__init__.py +29 -0
- novacode_cli/security/approval_context.py +45 -0
- novacode_cli/security/delegated_approval.py +68 -0
- novacode_cli/security/local_http.py +85 -0
- novacode_cli/security/middleware.py +87 -0
- novacode_cli/security/paths.py +35 -0
- novacode_cli/security/policy.py +535 -0
- novacode_cli/security/policy_writer.py +90 -0
- novacode_cli/security/prompt_injection.py +203 -0
- novacode_cli/security/remember.py +81 -0
- novacode_cli/security/rule_synthesis.py +116 -0
- novacode_cli/security/secret_files.py +61 -0
- novacode_cli/security/session_allow.py +114 -0
- novacode_cli/security/unicode_security.py +516 -0
- novacode_cli/security/validator.py +173 -0
- novacode_cli/server/__init__.py +9 -0
- novacode_cli/server/app.py +632 -0
- novacode_cli/server/event_adapter.py +189 -0
- novacode_cli/server/session_manager.py +90 -0
- novacode_cli/server_main.py +64 -0
- novacode_cli/server_runner/dev_server.py +530 -0
- novacode_cli/server_runner/test_runner.py +470 -0
- novacode_cli/session/__init__.py +1 -0
- novacode_cli/session/adapters/__init__.py +240 -0
- novacode_cli/session/adapters/claude_code.py +102 -0
- novacode_cli/session/adapters/codex.py +77 -0
- novacode_cli/session/adapters/harness_native.py +75 -0
- novacode_cli/session/checkpoint_coordinator.py +448 -0
- novacode_cli/session/context_restore.py +25 -0
- novacode_cli/session/history_export.py +228 -0
- novacode_cli/session/history_tree.py +174 -0
- novacode_cli/session/imported_context.py +267 -0
- novacode_cli/session/runtime_identity.py +32 -0
- novacode_cli/session/session_persistence.py +1168 -0
- novacode_cli/session/session_prompt_builder.py +279 -0
- novacode_cli/session/session_restore.py +351 -0
- novacode_cli/session/session_summarization.py +20 -0
- novacode_cli/session/transcript_journal.py +153 -0
- novacode_cli/sessions/__init__.py +11 -0
- novacode_cli/sessions/gateway.py +84 -0
- novacode_cli/sessions/launch.py +51 -0
- novacode_cli/sessions/lease.py +213 -0
- novacode_cli/sessions/messaging.py +255 -0
- novacode_cli/sessions/protocol.py +216 -0
- novacode_cli/sessions/resume.py +31 -0
- novacode_cli/sessions/supervisor.py +498 -0
- novacode_cli/sessions/tasks.py +73 -0
- novacode_cli/sessions/tui_messaging.py +413 -0
- novacode_cli/sessions/worker.py +652 -0
- novacode_cli/sessions/worktree.py +262 -0
- novacode_cli/shell/__init__.py +27 -0
- novacode_cli/shell/jobs.py +475 -0
- novacode_cli/shell/middleware.py +1568 -0
- novacode_cli/shell/os_sandbox.py +205 -0
- novacode_cli/shell/output.py +181 -0
- novacode_cli/shell/patterns.py +275 -0
- novacode_cli/shell/processes.py +87 -0
- novacode_cli/shell/utils.py +236 -0
- novacode_cli/skills/__init__.py +26 -0
- novacode_cli/skills/builtin/handoff/SKILL.md +15 -0
- novacode_cli/skills/curation_middleware.py +69 -0
- novacode_cli/skills/executable.py +161 -0
- novacode_cli/skills/libraries.py +102 -0
- novacode_cli/skills/load.py +248 -0
- novacode_cli/skills/prune.py +215 -0
- novacode_cli/skills/refreshing_middleware.py +896 -0
- novacode_cli/skills/retrieval.py +248 -0
- novacode_cli/skills/runtime.py +118 -0
- novacode_cli/skills/schema.py +254 -0
- novacode_cli/skills/skill_creation.py +1549 -0
- novacode_cli/skills/skill_lock.py +75 -0
- novacode_cli/skills/skill_system_prompt.py +16 -0
- novacode_cli/skills/skills_prefs.py +126 -0
- novacode_cli/skills/tool_resolver.py +31 -0
- novacode_cli/skills/upstream.py +50 -0
- novacode_cli/skills/versioning.py +291 -0
- novacode_cli/states/Session.py +847 -0
- novacode_cli/states/__init__.py +31 -0
- novacode_cli/states/slices/__init__.py +17 -0
- novacode_cli/states/slices/agent_runtime.py +249 -0
- novacode_cli/states/slices/background_tasks.py +21 -0
- novacode_cli/states/slices/notifications.py +74 -0
- novacode_cli/states/slices/remote_bridge.py +56 -0
- novacode_cli/states/slices/ui_settings.py +49 -0
- novacode_cli/states/slices/wiki.py +25 -0
- novacode_cli/teammates/__init__.py +1 -0
- novacode_cli/teammates/controller.py +557 -0
- novacode_cli/teammates/graph.py +104 -0
- novacode_cli/teammates/registry.py +175 -0
- novacode_cli/teammates/service.py +254 -0
- novacode_cli/teammates/worker.py +19 -0
- novacode_cli/teams/__init__.py +1 -0
- novacode_cli/teams/adapters/__init__.py +1 -0
- novacode_cli/teams/adapters/container.py +316 -0
- novacode_cli/teams/adapters/inprocess.py +206 -0
- novacode_cli/teams/adapters/process.py +364 -0
- novacode_cli/teams/bootstrap.py +246 -0
- novacode_cli/teams/container_git.py +67 -0
- novacode_cli/teams/context.py +34 -0
- novacode_cli/teams/coordinator.py +815 -0
- novacode_cli/teams/gateway.py +200 -0
- novacode_cli/teams/host.py +64 -0
- novacode_cli/teams/legacy.py +52 -0
- novacode_cli/teams/middleware.py +176 -0
- novacode_cli/teams/models.py +104 -0
- novacode_cli/teams/output.py +47 -0
- novacode_cli/teams/protocol.py +50 -0
- novacode_cli/teams/recovery.py +80 -0
- novacode_cli/teams/scheduler.py +44 -0
- novacode_cli/teams/store.py +631 -0
- novacode_cli/teams/worker.py +251 -0
- novacode_cli/teams/workspace.py +100 -0
- novacode_cli/token_utils.py +256 -0
- novacode_cli/tools/__init__.py +104 -0
- novacode_cli/tools/_shared.py +97 -0
- novacode_cli/tools/_types.py +24 -0
- novacode_cli/tools/artifact_tools.py +137 -0
- novacode_cli/tools/code_search_tools.py +330 -0
- novacode_cli/tools/computer_tools.py +77 -0
- novacode_cli/tools/cron_tools.py +65 -0
- novacode_cli/tools/daemon_tool.py +290 -0
- novacode_cli/tools/desktop_tools.py +161 -0
- novacode_cli/tools/fetch_tools.py +530 -0
- novacode_cli/tools/http_tools.py +279 -0
- novacode_cli/tools/inspection_tools.py +107 -0
- novacode_cli/tools/job_tools.py +143 -0
- novacode_cli/tools/knowledge_tools.py +427 -0
- novacode_cli/tools/memory_tools.py +461 -0
- novacode_cli/tools/memory_wiki_tools.py +40 -0
- novacode_cli/tools/notebook_tools.py +238 -0
- novacode_cli/tools/oracle_tool.py +83 -0
- novacode_cli/tools/plan_archive_tools.py +80 -0
- novacode_cli/tools/plan_mode_tools.py +315 -0
- novacode_cli/tools/python_kernel_loop.py +165 -0
- novacode_cli/tools/python_kernel_tool.py +222 -0
- novacode_cli/tools/reflection_tools.py +34 -0
- novacode_cli/tools/scraper_tools.py +377 -0
- novacode_cli/tools/skill_tools.py +275 -0
- novacode_cli/tools/speak_tool.py +51 -0
- novacode_cli/tools/store_memory_tools.py +227 -0
- novacode_cli/tools/ui_tools.py +62 -0
- novacode_cli/tools/waiter_tools.py +408 -0
- novacode_cli/tools/web_tools.py +349 -0
- novacode_cli/tools/wiki_tools.py +152 -0
- novacode_cli/tools/worktree_tools.py +219 -0
- novacode_cli/tracking/__init__.py +10 -0
- novacode_cli/tracking/file_tracker.py +826 -0
- novacode_cli/tracking/local_metrics.py +170 -0
- novacode_cli/tracking/loop_guard.py +507 -0
- novacode_cli/tracking/request_metrics.py +60 -0
- novacode_cli/tracking/tracing.py +692 -0
- novacode_cli/tracking/usage_tree.py +677 -0
- novacode_cli/tracking/workspace_anchoring.py +231 -0
- novacode_cli/tui/__init__.py +11 -0
- novacode_cli/tui/animation_rate.py +9 -0
- novacode_cli/tui/animations.py +332 -0
- novacode_cli/tui/app.py +19315 -0
- novacode_cli/tui/auth_display.py +87 -0
- novacode_cli/tui/auth_screens.py +826 -0
- novacode_cli/tui/avatar.py +294 -0
- novacode_cli/tui/avatar_frames.py +21 -0
- novacode_cli/tui/background_tasks.py +35 -0
- novacode_cli/tui/browser_settings_screen.py +70 -0
- novacode_cli/tui/caveman_screen.py +185 -0
- novacode_cli/tui/council_settings_screen.py +182 -0
- novacode_cli/tui/council_view.py +535 -0
- novacode_cli/tui/decision_settings.py +268 -0
- novacode_cli/tui/diff_preview.py +199 -0
- novacode_cli/tui/gc_tuning.py +63 -0
- novacode_cli/tui/handoff_screen.py +206 -0
- novacode_cli/tui/harness.py +244 -0
- novacode_cli/tui/history_tree_screen.py +179 -0
- novacode_cli/tui/home.py +328 -0
- novacode_cli/tui/home_scene.py +94 -0
- novacode_cli/tui/icons.py +81 -0
- novacode_cli/tui/interface_styles.tcss +327 -0
- novacode_cli/tui/matrix3.py +108 -0
- novacode_cli/tui/mcp_tools_screen.py +131 -0
- novacode_cli/tui/motion.py +178 -0
- novacode_cli/tui/output_body.py +136 -0
- novacode_cli/tui/output_buffer.py +59 -0
- novacode_cli/tui/palette.py +334 -0
- novacode_cli/tui/pickers.py +237 -0
- novacode_cli/tui/prime_panel.py +506 -0
- novacode_cli/tui/provider_signin.py +213 -0
- novacode_cli/tui/pty_shell.py +159 -0
- novacode_cli/tui/reference_screens.py +444 -0
- novacode_cli/tui/resume_picker.py +346 -0
- novacode_cli/tui/rooms.py +597 -0
- novacode_cli/tui/screens.py +7088 -0
- novacode_cli/tui/session_import.py +333 -0
- novacode_cli/tui/session_pane.py +242 -0
- novacode_cli/tui/session_tab.py +42 -0
- novacode_cli/tui/session_transcript.py +118 -0
- novacode_cli/tui/shell_output.py +7 -0
- novacode_cli/tui/stall_watch.py +174 -0
- novacode_cli/tui/statusline.py +40 -0
- novacode_cli/tui/statusline_screen.py +91 -0
- novacode_cli/tui/teammate_updates.py +162 -0
- novacode_cli/tui/teammates.py +548 -0
- novacode_cli/tui/terminal.py +289 -0
- novacode_cli/tui/terminal_view.py +235 -0
- novacode_cli/tui/themes.py +118 -0
- novacode_cli/tui/update_notice.py +84 -0
- novacode_cli/tui/voice_downloads.py +113 -0
- novacode_cli/tui/widgets.py +2038 -0
- novacode_cli/tui/windows_input.py +38 -0
- novacode_cli/tui/workspace_approval.py +165 -0
- novacode_cli/ui/council_web.py +113 -0
- novacode_cli/ui/council_workspace.py +72 -0
- novacode_cli/ui/cowork_web.py +41 -0
- novacode_cli/ui/diff_highlight.py +160 -0
- novacode_cli/ui/execution.py +1396 -0
- novacode_cli/ui/exit_summary.py +65 -0
- novacode_cli/ui/hitl_approval.py +196 -0
- novacode_cli/ui/input_preparation.py +2 -0
- novacode_cli/ui/interrupt_handlers.py +215 -0
- novacode_cli/ui/question_prompt.py +151 -0
- novacode_cli/ui/status_phrases.py +157 -0
- novacode_cli/ui/stream_preview.py +66 -0
- novacode_cli/ui/streaming.py +2 -0
- novacode_cli/ui/subagent_tracking.py +2 -0
- novacode_cli/ui/ui_elements.py +1585 -0
- novacode_cli/ui/web_workspace.py +244 -0
- novacode_cli/ui_events.py +299 -0
- novacode_cli/update_ui.py +243 -0
- novacode_cli/updates.py +489 -0
- novacode_cli/utils/backend_patches.py +399 -0
- novacode_cli/utils/backend_paths.py +467 -0
- novacode_cli/utils/lazy_providers.py +51 -0
- novacode_cli/utils/model_info.py +223 -0
- novacode_cli/utils/pdf_extraction.py +183 -0
- novacode_cli/utils/startup.py +29 -0
- novacode_cli/utils/tasks.py +42 -0
- novacode_cli/utils/windows.py +9 -0
- novacode_cli/vixie/hook_adapter.py +127 -0
- novacode_cli/vixie/server.py +375 -0
- novacode_cli/wiki/__init__.py +15 -0
- novacode_cli/wiki/ask.py +56 -0
- novacode_cli/wiki/file.py +71 -0
- novacode_cli/wiki/ingest.py +184 -0
- novacode_cli/wiki/manager.py +261 -0
- novacode_cli-1.2.0.dist-info/METADATA +271 -0
- novacode_cli-1.2.0.dist-info/RECORD +691 -0
- novacode_cli-1.2.0.dist-info/WHEEL +5 -0
- novacode_cli-1.2.0.dist-info/entry_points.txt +2 -0
- novacode_cli-1.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|