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,1357 @@
|
|
|
1
|
+
"""Handler for /chat command — launches a local **Council** web UI.
|
|
2
|
+
|
|
3
|
+
The ``/chat`` web UI runs a *council of agents*: five personas debate a topic
|
|
4
|
+
chatroom-style, then score each other; the highest-scoring answer is the
|
|
5
|
+
verdict. The server runs in a background thread
|
|
6
|
+
(``http.server.ThreadingHTTPServer``) and streams the council over **Server-Sent
|
|
7
|
+
Events** to the browser, driving the same LangGraph session's configured model
|
|
8
|
+
via ``asyncio.run_coroutine_threadsafe`` onto the CLI event loop.
|
|
9
|
+
|
|
10
|
+
The council logic itself lives in :mod:`novacode_cli.council`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import queue
|
|
19
|
+
import secrets
|
|
20
|
+
import socket
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import webbrowser
|
|
25
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
26
|
+
from typing import Any
|
|
27
|
+
from urllib.parse import parse_qs, urlparse
|
|
28
|
+
|
|
29
|
+
from novacode_cli.config.config import COLORS, console
|
|
30
|
+
from novacode_cli.ui.council_workspace import (WORKSPACE_CONTROLS_HTML, WORKSPACE_CONTROLS_SCRIPT, WORKSPACE_CONTROLS_STYLE, WORKSPACE_METRICS_HTML)
|
|
31
|
+
from novacode_cli.ui.council_web import COUNCIL_STYLE, COUNCIL_SETTINGS_HTML, COUNCIL_SETTINGS_SCRIPT
|
|
32
|
+
from novacode_cli.ui.web_workspace import WORKSPACE_CSS, WORKSPACE_SCRIPT
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# Sentinel pushed onto the event queue when the council run finishes.
|
|
37
|
+
_STREAM_END = object()
|
|
38
|
+
_pending_answers: dict[str, queue.Queue] = {}
|
|
39
|
+
_pending_lock = threading.Lock()
|
|
40
|
+
_producers: set = set()
|
|
41
|
+
_HEARTBEAT_SECONDS = 10
|
|
42
|
+
_IDLE_TIMEOUT = 300
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Module-level state shared with the background HTTP server thread.
|
|
46
|
+
# Set by set_agent_refs before the server thread starts.
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
_agent: Any = None
|
|
50
|
+
_assistant_id: str | None = None
|
|
51
|
+
_session_state: Any = None
|
|
52
|
+
_main_loop: asyncio.AbstractEventLoop | None = None
|
|
53
|
+
|
|
54
|
+
_server: ThreadingHTTPServer | None = None
|
|
55
|
+
_server_thread: threading.Thread | None = None
|
|
56
|
+
_server_port: int | None = None
|
|
57
|
+
|
|
58
|
+
# Only one council can be in session at a time (a single shared model/loop).
|
|
59
|
+
_run_lock = threading.Lock()
|
|
60
|
+
_settings_lock = threading.Lock()
|
|
61
|
+
|
|
62
|
+
# Prior council rounds (this server's lifetime), so follow-up topics can build on
|
|
63
|
+
# the earlier discussion. Each round: {"topic", "transcript": [[name, text]...],
|
|
64
|
+
# "winner"}. Guarded by _run_lock (only one run mutates it at a time). Reset via
|
|
65
|
+
# GET /api/council/reset or when the server stops.
|
|
66
|
+
_council_history: list[dict[str, Any]] = []
|
|
67
|
+
|
|
68
|
+
# How many rounds to retain. The prompt only reads the last few
|
|
69
|
+
# (council._MAX_HISTORY_ROUNDS); a small margin over that lets the UI show a
|
|
70
|
+
# little more scrollback without the list growing without bound.
|
|
71
|
+
_MAX_KEPT_ROUNDS = 12
|
|
72
|
+
|
|
73
|
+
# The user's verdict on each round: the council recommends, the user settles.
|
|
74
|
+
# Each entry is {"topic", "decision", "winner", "at"} where decision is one of
|
|
75
|
+
# DECISION_KEYS. Bounded like _council_history — a long-lived server would
|
|
76
|
+
# otherwise accumulate one record per round forever. Guarded by _run_lock.
|
|
77
|
+
_council_decisions: list[dict[str, Any]] = []
|
|
78
|
+
|
|
79
|
+
# The only decisions the server will record. Anything else is a client bug (or
|
|
80
|
+
# something poking the endpoint), and is rejected rather than stored.
|
|
81
|
+
DECISION_KEYS = ("approve", "revise", "reject")
|
|
82
|
+
|
|
83
|
+
# Refuse absurd bodies outright: this is a localhost endpoint and a decision is
|
|
84
|
+
# four short fields, so a large one is never legitimate.
|
|
85
|
+
_MAX_DECISION_BODY = 8 * 1024
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Embedded HTML council page
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _make_chat_html() -> str:
|
|
93
|
+
"""Render the council with an advisor sidebar and a dedicated chat area."""
|
|
94
|
+
return r"""<!DOCTYPE html>
|
|
95
|
+
<html lang="en">
|
|
96
|
+
<head>
|
|
97
|
+
<meta charset="UTF-8">
|
|
98
|
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
99
|
+
<title>Nova — Council</title>
|
|
100
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
|
101
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
102
|
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
103
|
+
<style>
|
|
104
|
+
/* --- the bench — advisor seats with live state + vote tally --- */
|
|
105
|
+
#bench { display: none; flex-shrink: 0; gap: 8px; padding: 10px 14px;
|
|
106
|
+
border-bottom: 1px solid var(--border); background: var(--surface); overflow-x: auto; }
|
|
107
|
+
#bench.active { display: flex; }
|
|
108
|
+
#bench .seat { flex: 1 1 0; min-width: 92px; display: flex; flex-direction: column;
|
|
109
|
+
align-items: center; gap: 4px; padding: 8px 6px; border-radius: 4px;
|
|
110
|
+
border: 1px solid var(--border); border-left: 3px solid var(--seat, var(--border));
|
|
111
|
+
background: var(--panel); transition: border-color .3s, box-shadow .3s, opacity .3s, transform .2s;
|
|
112
|
+
opacity: .5; }
|
|
113
|
+
#bench .seat-av { position: relative; font-size: 20px; width: 36px; height: 36px;
|
|
114
|
+
border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
|
115
|
+
border: 1px solid var(--seat, var(--border)); background: var(--bg); }
|
|
116
|
+
#bench .seat-badge { position: absolute; top: -6px; right: -8px; min-width: 17px; height: 17px;
|
|
117
|
+
padding: 0 4px; border-radius: 9px; font-size: 10px; font-weight: 700; line-height: 17px;
|
|
118
|
+
text-align: center; background: var(--panel); color: var(--muted); font-family: inherit;
|
|
119
|
+
opacity: 0; transform: scale(.6); transition: opacity .25s, transform .25s, background .25s, color .25s; }
|
|
120
|
+
#bench .seat-badge.has { opacity: 1; transform: scale(1); background: var(--warning); color: #1a1410; }
|
|
121
|
+
#bench .seat-name { font-size: 10.5px; font-weight: 600; color: var(--fg); text-align: center; }
|
|
122
|
+
#bench .seat-state { font-size: 9px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
|
|
123
|
+
#bench .seat[data-state="speaking"] { opacity: 1; border-color: var(--seat); box-shadow: 0 0 0 1px var(--seat); }
|
|
124
|
+
#bench .seat[data-state="speaking"] .seat-av { animation: pulse 1.3s ease-in-out infinite; }
|
|
125
|
+
#bench .seat[data-state="answered"] { opacity: 1; }
|
|
126
|
+
#bench .seat[data-state="answered"] .seat-state { color: var(--seat); }
|
|
127
|
+
#bench .seat[data-state="voting"] { opacity: 1; }
|
|
128
|
+
#bench .seat[data-state="voting"] .seat-state { color: var(--warning); }
|
|
129
|
+
#bench .seat[data-state="winner"] { opacity: 1; border-color: var(--warning);
|
|
130
|
+
box-shadow: 0 0 0 1px var(--warning), 0 0 22px rgba(224,175,104,.25); transform: translateY(-2px); }
|
|
131
|
+
#bench .seat[data-state="winner"] .seat-state { color: var(--warning); font-weight: 700; }
|
|
132
|
+
|
|
133
|
+
/* --- messages --- */
|
|
134
|
+
#messages { flex: 1; overflow-y: auto; padding: 20px 16px; display: flex;
|
|
135
|
+
flex-direction: column; gap: 12px; scroll-behavior: smooth; }
|
|
136
|
+
#messages::-webkit-scrollbar { width: 8px; }
|
|
137
|
+
#messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
|
138
|
+
|
|
139
|
+
.topic-banner { align-self: center; max-width: 80%; text-align: center; color: var(--muted);
|
|
140
|
+
font-size: 13px; padding: 6px 18px; border-bottom: 1px solid var(--border); }
|
|
141
|
+
|
|
142
|
+
/* agent message — accent-bar card like the TUI transcript */
|
|
143
|
+
.agent { max-width: 84%; align-self: flex-start; background: var(--surface);
|
|
144
|
+
border-left: 3px solid var(--seat, var(--primary)); padding: 10px 14px;
|
|
145
|
+
animation: rise .3s ease both; }
|
|
146
|
+
.agent .who { display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
|
|
147
|
+
font-weight: 700; font-size: 11px; color: var(--seat, var(--fg)); }
|
|
148
|
+
.agent .who .avatar { font-size: 14px; }
|
|
149
|
+
.agent .who-tag { margin-left: auto; font-size: 9px; font-weight: 500; text-transform: uppercase;
|
|
150
|
+
letter-spacing: .08em; color: var(--muted); border: 1px solid var(--border); padding: 1px 6px;
|
|
151
|
+
border-radius: 10px; white-space: nowrap; }
|
|
152
|
+
.agent .search-chip { font-size: 11px; color: var(--warning); margin-bottom: 6px; opacity: .9;
|
|
153
|
+
word-break: break-word; }
|
|
154
|
+
.agent .body { line-height: 1.6; font-size: 13px; color: var(--fg); }
|
|
155
|
+
.agent .body p { margin: 0 0 8px; }
|
|
156
|
+
.agent .body p:last-child { margin-bottom: 0; }
|
|
157
|
+
.agent .body pre { background: var(--bg) !important; border: 1px solid var(--border);
|
|
158
|
+
border-left: 3px solid var(--accent); border-radius: 3px; padding: 10px 12px; overflow-x: auto;
|
|
159
|
+
margin: 8px 0; font-size: 12px; }
|
|
160
|
+
.agent .body code { background: var(--boost); border: 1px solid var(--border); padding: 1px 5px;
|
|
161
|
+
border-radius: 3px; font-size: 12px; color: var(--secondary); }
|
|
162
|
+
.agent .body pre code { background: none !important; border: 0; padding: 0 !important; color: var(--fg); }
|
|
163
|
+
.agent.streaming .body::after { content: '▍'; color: var(--seat, var(--primary));
|
|
164
|
+
animation: blink 1s steps(1) infinite; margin-left: 1px; }
|
|
165
|
+
|
|
166
|
+
/* phase divider */
|
|
167
|
+
.phase { align-self: center; color: var(--accent); font-size: 10px; text-transform: uppercase;
|
|
168
|
+
letter-spacing: .2em; font-weight: 700; margin: 8px 0 2px; display: flex; align-items: center; gap: 12px; }
|
|
169
|
+
.phase::before, .phase::after { content: ''; height: 1px; width: 60px; background: var(--border); }
|
|
170
|
+
.phase-sub { text-transform: none; letter-spacing: 0; font-weight: 400; color: var(--muted);
|
|
171
|
+
font-size: 10px; margin-left: 10px; }
|
|
172
|
+
|
|
173
|
+
/* one-line democratic votes */
|
|
174
|
+
.vote-row { align-self: stretch; max-width: 84%; display: flex; flex-wrap: wrap;
|
|
175
|
+
align-items: baseline; gap: 8px; padding: 6px 12px; font-size: 12px;
|
|
176
|
+
border-left: 3px solid var(--seat, var(--primary)); background: var(--surface);
|
|
177
|
+
animation: rise .25s ease both; }
|
|
178
|
+
.vote-row .v-voter { font-weight: 700; color: var(--seat, var(--fg)); }
|
|
179
|
+
.vote-row .v-arrow { color: var(--muted); }
|
|
180
|
+
.vote-row .v-choice { font-weight: 700; }
|
|
181
|
+
.vote-row .v-choice.muted { color: var(--muted); font-weight: 400; font-style: italic; }
|
|
182
|
+
.vote-row .v-why { flex-basis: 100%; color: var(--muted); font-size: 11px; }
|
|
183
|
+
|
|
184
|
+
/* verdict */
|
|
185
|
+
.verdict { align-self: stretch; margin: 8px 0; background: var(--surface);
|
|
186
|
+
border: 1px solid var(--border); border-left: 4px solid var(--warning); border-radius: 4px;
|
|
187
|
+
padding: 16px 18px; animation: rise .4s ease both; }
|
|
188
|
+
.verdict h3 { font-size: 14px; color: var(--fg); margin-bottom: 10px; display: flex;
|
|
189
|
+
align-items: center; gap: 10px; font-weight: 800; }
|
|
190
|
+
.verdict h3 .w-name { color: var(--warning); }
|
|
191
|
+
.verdict-sub { color: var(--muted); font-size: 11px; margin: -4px 0 12px; letter-spacing: .03em; }
|
|
192
|
+
.leaderboard { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
|
193
|
+
.lb-row { display: flex; align-items: center; gap: 10px; font-size: 12px; }
|
|
194
|
+
.lb-row .lb-name { width: 150px; color: var(--muted); flex-shrink: 0; overflow: hidden;
|
|
195
|
+
text-overflow: ellipsis; white-space: nowrap; }
|
|
196
|
+
.lb-row.win .lb-name { color: var(--fg); font-weight: 700; }
|
|
197
|
+
.lb-bar { flex: 1; height: 8px; background: var(--panel); border-radius: 4px; overflow: hidden; }
|
|
198
|
+
.lb-fill { height: 100%; background: var(--seat, var(--primary)); border-radius: 4px;
|
|
199
|
+
transition: width .6s ease; }
|
|
200
|
+
.lb-row .lb-pts { width: 60px; text-align: right; color: var(--warning); font-weight: 600;
|
|
201
|
+
font-variant-numeric: tabular-nums; }
|
|
202
|
+
.winner-label { margin-top: 4px; font-size: 9.5px; text-transform: uppercase; letter-spacing: .12em;
|
|
203
|
+
color: var(--muted); }
|
|
204
|
+
.verdict .winner-answer { border-top: 1px solid var(--border); padding-top: 10px; line-height: 1.6;
|
|
205
|
+
font-size: 13px; color: var(--fg); }
|
|
206
|
+
.verdict .winner-answer pre { background: var(--bg) !important; border: 1px solid var(--border);
|
|
207
|
+
border-left: 3px solid var(--accent); padding: 10px 12px; border-radius: 3px; overflow-x: auto; }
|
|
208
|
+
|
|
209
|
+
/* your approval — the council recommends, you decide */
|
|
210
|
+
.approval { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
|
|
211
|
+
.approval-lead { font-size: 10px; text-transform: uppercase; letter-spacing: .12em;
|
|
212
|
+
color: var(--muted); margin-bottom: 9px; }
|
|
213
|
+
.approval-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
214
|
+
.approval-row button { font: inherit; font-size: 11px; padding: 5px 12px; cursor: pointer;
|
|
215
|
+
background: var(--panel); color: var(--fg); border: 1px solid var(--border);
|
|
216
|
+
border-radius: 3px; transition: border-color .15s, color .15s, background .15s; }
|
|
217
|
+
.approval-row button:hover { border-color: var(--primary); background: var(--boost); }
|
|
218
|
+
.approval-row button[data-decision="approve"] { border-color: var(--success); color: var(--success); }
|
|
219
|
+
.approval-row button[data-decision="approve"]:hover { background: rgba(115,218,202,.12); }
|
|
220
|
+
.approval-row button[data-decision="revise"] { border-color: var(--warning); color: var(--warning); }
|
|
221
|
+
.approval-row button[data-decision="revise"]:hover { background: rgba(224,175,104,.12); }
|
|
222
|
+
.approval-row button[data-decision="reject"] { border-color: var(--error); color: var(--error); }
|
|
223
|
+
.approval-row button[data-decision="reject"]:hover { background: rgba(247,118,142,.12); }
|
|
224
|
+
.approval-state { font-size: 11.5px; display: flex; align-items: center; gap: 8px; }
|
|
225
|
+
.approval-state[data-decision="approve"] { color: var(--success); }
|
|
226
|
+
.approval-state[data-decision="revise"] { color: var(--warning); }
|
|
227
|
+
.approval-state[data-decision="reject"] { color: var(--error); }
|
|
228
|
+
.approval-note { color: var(--muted); font-size: 10.5px; }
|
|
229
|
+
|
|
230
|
+
.error-msg { align-self: center; color: var(--error); font-size: 12px; padding: 8px 18px;
|
|
231
|
+
text-align: center; background: rgba(247,118,142,.08); border-radius: 4px;
|
|
232
|
+
border: 1px solid rgba(247,118,142,.25); }
|
|
233
|
+
|
|
234
|
+
/* welcome */
|
|
235
|
+
.welcome { text-align: center; margin: auto; padding: 40px 24px; max-width: 520px;
|
|
236
|
+
animation: rise .5s ease both; }
|
|
237
|
+
.welcome h2 { font-weight: 800; font-size: 22px; color: var(--fg); margin-bottom: 6px; }
|
|
238
|
+
.welcome .divider { width: 40px; height: 2px; background: var(--primary); margin: 14px auto; }
|
|
239
|
+
.welcome p { font-size: 12.5px; line-height: 1.7; color: var(--muted); margin-bottom: 8px; }
|
|
240
|
+
.welcome .seats { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 14px; }
|
|
241
|
+
.welcome .seat { font-size: 11px; padding: 3px 10px; border: 1px solid var(--border);
|
|
242
|
+
border-radius: 20px; color: var(--muted); }
|
|
243
|
+
|
|
244
|
+
/* --- composer (TUI prompt dock) --- */
|
|
245
|
+
#decision-options, #decision-criteria { width:100%; box-sizing:border-box; background:var(--panel); color:var(--fg); border:1px solid var(--border); border-radius:8px; padding:10px; margin:8px 0 16px; font:inherit; }
|
|
246
|
+
.clarification { padding:20px; border:1px solid var(--primary); border-radius:12px; margin:16px 0; }
|
|
247
|
+
.clarification button, .clarification input { margin:8px 8px 0 0; padding:10px; max-width:100%; }
|
|
248
|
+
#input-area { display: flex; align-items: stretch; border-top: 1px solid var(--border);
|
|
249
|
+
background: var(--panel); flex-shrink: 0; }
|
|
250
|
+
.mode-badge { display: flex; align-items: center; padding: 0 10px; font-size: 10px; font-weight: 700;
|
|
251
|
+
letter-spacing: .14em; text-transform: uppercase; color: #b4c6ef; background: #161f33;
|
|
252
|
+
border-right: 1px solid var(--primary); }
|
|
253
|
+
.prefix { display: flex; align-items: center; padding: 0 10px; color: var(--accent); font-weight: 800;
|
|
254
|
+
font-size: 16px; background: var(--panel); user-select: none; }
|
|
255
|
+
#input-area textarea { flex: 1; resize: none; min-height: 56px; max-height: 180px; background: var(--panel);
|
|
256
|
+
border: 0; color: var(--fg); font: inherit; font-size: 13px; padding: 16px 12px; outline: none;
|
|
257
|
+
transition: background .2s; }
|
|
258
|
+
#input-area textarea::placeholder { color: var(--muted); }
|
|
259
|
+
#input-area textarea:focus { background: var(--boost); }
|
|
260
|
+
#input-area textarea:disabled { opacity: .45; }
|
|
261
|
+
#input-area button { background: var(--primary); color: #0f0f16; border: 0; font: inherit; font-weight: 700;
|
|
262
|
+
font-size: 12px; padding: 0 18px; cursor: pointer; letter-spacing: .06em; transition: background .15s; }
|
|
263
|
+
#input-area button:hover:not(:disabled) { background: #8fb4ff; }
|
|
264
|
+
#input-area button:disabled { opacity: .4; cursor: not-allowed; }
|
|
265
|
+
|
|
266
|
+
/* --- status bar --- */
|
|
267
|
+
#status-bar { display: flex; align-items: center; gap: 16px; padding: 5px 14px;
|
|
268
|
+
background: var(--surface); border-top: 1px solid var(--border); font-size: 11px; color: var(--muted);
|
|
269
|
+
flex: 0 0 auto; animation: rise .4s ease both; animation-delay: .24s; }
|
|
270
|
+
#status-bar .dot.on { color: var(--success); }
|
|
271
|
+
#status-bar .dot.off { color: var(--muted); }
|
|
272
|
+
#status-bar .spacer { flex: 1; }
|
|
273
|
+
#status-bar .hint { color: var(--muted); }
|
|
274
|
+
/* The running phase message. Clamped so a long name ("The Skeptic is forming
|
|
275
|
+
an answer…") cannot push the clock off a narrow window. */
|
|
276
|
+
.status-msg { flex: 0 1 auto; min-width: 0; max-width: 46vw; overflow: hidden;
|
|
277
|
+
text-overflow: ellipsis; white-space: nowrap; color: var(--fg); opacity: .85; }
|
|
278
|
+
.status-msg:empty { display: none; }
|
|
279
|
+
|
|
280
|
+
/* --- motion --- */
|
|
281
|
+
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
282
|
+
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
|
|
283
|
+
@keyframes blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } }
|
|
284
|
+
@keyframes pulse { 0%,100% { opacity: .75; } 50% { opacity: 1; } }
|
|
285
|
+
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
|
286
|
+
__WORKSPACE_STYLE__
|
|
287
|
+
__COUNCIL_STYLE__
|
|
288
|
+
__WORKSPACE_CONTROLS_STYLE__
|
|
289
|
+
</style>
|
|
290
|
+
</head>
|
|
291
|
+
<body class="workspace-app council-app">
|
|
292
|
+
<aside class="workspace-sidebar" id="workspace-sidebar" aria-label="Council sidebar">
|
|
293
|
+
<div class="workspace-brand"><span class="brand-icon" aria-hidden="true">◆</span>
|
|
294
|
+
<span>Nova</span><span class="brand-product">Council</span>
|
|
295
|
+
<button class="sidebar-close" id="sidebar-close" aria-label="Close sidebar">×</button></div>
|
|
296
|
+
<div class="sidebar-content">
|
|
297
|
+
<button id="new-thread" class="sidebar-primary" data-return-to-chat title="Clear the prior session and start fresh">+ New discussion</button>
|
|
298
|
+
<h2 class="sidebar-label">Your advisors</h2>
|
|
299
|
+
<div id="bench" aria-label="Council advisors"></div>
|
|
300
|
+
<p class="sidebar-note advisor-placeholder">Your council assembles when you send a question. Follow each advisor's progress here.</p>
|
|
301
|
+
<h2 class="sidebar-label">Decision brief</h2>
|
|
302
|
+
<label for="decision-options">Options · one per line (2–8)</label>
|
|
303
|
+
<textarea id="decision-options" rows="4" placeholder="Build internally Buy a service Delay the decision"></textarea>
|
|
304
|
+
<label for="decision-criteria">Criteria and constraints</label>
|
|
305
|
+
<textarea id="decision-criteria" rows="3" placeholder="Budget, deadline, priorities, risk tolerance…"></textarea>
|
|
306
|
+
<p class="advisor-placeholder">Add options to enable advisor questions and option voting. Leave empty for an open discussion.</p>
|
|
307
|
+
<button id="stop-council" type="button">Stop discussion</button>
|
|
308
|
+
<h2 class="sidebar-label">How it works</h2>
|
|
309
|
+
<ol class="workflow"><li>Independent perspectives</li><li>Questions, trade-offs, and voting</li><li>Chairman synthesis</li><li>The final decision is yours</li></ol>
|
|
310
|
+
</div>
|
|
311
|
+
<div class="sidebar-footer"><span>Follow-ups stay in this discussion.</span>__WORKSPACE_METRICS_HTML__<span class="sid" id="sid"></span></div>
|
|
312
|
+
</aside>
|
|
313
|
+
<main class="workspace-chat" id="conversation">
|
|
314
|
+
<header class="chat-topbar"><button class="mobile-menu" id="sidebar-toggle" aria-controls="workspace-sidebar"
|
|
315
|
+
aria-expanded="false" aria-label="Open sidebar">☰</button><div class="chat-heading"><h1>Council</h1>
|
|
316
|
+
<p>A few perspectives. A clearer decision.</p></div><button type="button" class="settings-open" data-council-settings>Council settings</button><span id="run-badge" class="run-badge">● convening</span></header>
|
|
317
|
+
__WORKSPACE_CONTROLS_HTML__
|
|
318
|
+
<div id="messages" role="tabpanel" aria-labelledby="view-synthesis" aria-live="polite" tabindex="0">
|
|
319
|
+
<div class="welcome"><div class="welcome-icon">MULTI-AGENT DELIBERATION</div>
|
|
320
|
+
<h2>One question. Independent perspectives.</h2>
|
|
321
|
+
<p>Broadcast your task to specialist advisors in parallel. Review their debate or follow the chairman’s consolidated answer. Choose a preset or use your saved council.</p>
|
|
322
|
+
<div class="prompt-options">
|
|
323
|
+
<button class="prompt-option" data-prompt="Help me compare two approaches to ">Compare approaches</button>
|
|
324
|
+
<button class="prompt-option" data-prompt="Review this decision and challenge my assumptions: ">Review a decision</button>
|
|
325
|
+
<button class="prompt-option" data-prompt="What trade-offs should I consider for ">Explore trade-offs</button>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
<div id="input-area">
|
|
330
|
+
<span class="mode-badge">council</span>
|
|
331
|
+
<span class="prefix">></span>
|
|
332
|
+
<textarea id="input" rows="1" placeholder="Ask your council a question…" aria-label="Message the council"></textarea>
|
|
333
|
+
<button id="send-btn">Convene</button>
|
|
334
|
+
</div>
|
|
335
|
+
<div id="status-bar">
|
|
336
|
+
<span id="conn" class="dot off">○ ready</span>
|
|
337
|
+
<span id="status-msg" class="status-msg"></span>
|
|
338
|
+
<span id="stat-run" class="dot off">○ idle</span>
|
|
339
|
+
<span class="spacer"></span>
|
|
340
|
+
<span class="hint">⏎ send · ⇧⏎ newline</span>
|
|
341
|
+
<span id="clock"></span>
|
|
342
|
+
</div>
|
|
343
|
+
</main>
|
|
344
|
+
<button class="workspace-backdrop" id="workspace-backdrop" tabindex="-1"
|
|
345
|
+
aria-label="Close sidebar"></button>
|
|
346
|
+
|
|
347
|
+
<script>
|
|
348
|
+
const $ = id => document.getElementById(id);
|
|
349
|
+
const messages = $('messages');
|
|
350
|
+
const input = $('input');
|
|
351
|
+
const sendBtn = $('send-btn');
|
|
352
|
+
const bench = $('bench');
|
|
353
|
+
let es = null;
|
|
354
|
+
|
|
355
|
+
const agentEls = {}; // id -> {wrap, body, buf}
|
|
356
|
+
const benchSeats = {}; // id -> {seat, badge, votes}
|
|
357
|
+
let agentMeta = {}; // id -> {name, avatar, color}
|
|
358
|
+
|
|
359
|
+
// The running message lives in its OWN child span. Assigning
|
|
360
|
+
// `statusBar.textContent` here instead would delete every child of the bar —
|
|
361
|
+
// #conn, #stat-run, #clock and all — on the first status update. setRun() then
|
|
362
|
+
// dereferenced the destroyed #stat-run and threw, and because convene() calls
|
|
363
|
+
// setRun(true) *before* `new EventSource(...)`, every follow-up question died
|
|
364
|
+
// before its stream was ever created: text consumed, composer left disabled,
|
|
365
|
+
// nothing rendered. That was the "follow-up chat does nothing" wedge, and a
|
|
366
|
+
// string-grep test for `endRun()` could never have caught it.
|
|
367
|
+
function setStatus(m) { $('status-msg').textContent = m; }
|
|
368
|
+
function setRun(on) {
|
|
369
|
+
$('run-badge').classList.toggle('show', on);
|
|
370
|
+
$('council-preset').disabled=on; $('clear-view').disabled=on;$('clear-view').hidden=on;$('workspace-stop').hidden=!on;
|
|
371
|
+
// Null-guarded: a status/render helper must never be able to wedge the
|
|
372
|
+
// composer. This bug shipped twice by throwing from exactly here.
|
|
373
|
+
const sr = $('stat-run');
|
|
374
|
+
if (!sr) return;
|
|
375
|
+
sr.textContent = on ? '● running' : '○ idle';
|
|
376
|
+
sr.className = 'dot ' + (on ? 'on' : 'off');
|
|
377
|
+
}
|
|
378
|
+
function atBottom() { return messages.scrollHeight - messages.scrollTop - messages.clientHeight < 100; }
|
|
379
|
+
function scrollDown(force) { if (force || atBottom()) messages.scrollTop = messages.scrollHeight; }
|
|
380
|
+
function dropWelcome() { const w = messages.querySelector('.welcome'); if (w) w.remove(); }
|
|
381
|
+
function highlight(el) { el.querySelectorAll('pre code').forEach(b => hljs.highlightElement(b)); }
|
|
382
|
+
function el(tag, cls) { const e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
|
383
|
+
|
|
384
|
+
function addError(text) {
|
|
385
|
+
dropWelcome();
|
|
386
|
+
const e = el('div', 'error-msg'); e.textContent = text;
|
|
387
|
+
messages.appendChild(e); scrollDown(true);
|
|
388
|
+
}
|
|
389
|
+
function addPhase(text, sub) {
|
|
390
|
+
const e = el('div', 'phase');
|
|
391
|
+
const t = el('span', 'phase-title'); t.textContent = text; e.appendChild(t);
|
|
392
|
+
if (sub) { const s = el('span', 'phase-sub'); s.textContent = sub; e.appendChild(s); }
|
|
393
|
+
messages.appendChild(e); scrollDown(true);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* --- clock --- */
|
|
397
|
+
(function(){
|
|
398
|
+
const c = document.getElementById('clock');
|
|
399
|
+
if (!c) return;
|
|
400
|
+
function tick(){ c.textContent = new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'}); }
|
|
401
|
+
tick(); setInterval(tick, 10000);
|
|
402
|
+
})();
|
|
403
|
+
|
|
404
|
+
/* --- the bench: the row of advisor seats at the top of the chamber --- */
|
|
405
|
+
function buildBench(agents) {
|
|
406
|
+
bench.innerHTML = '';
|
|
407
|
+
for (const k in benchSeats) delete benchSeats[k];
|
|
408
|
+
agents.forEach(a => {
|
|
409
|
+
agentMeta[a.id] = a;
|
|
410
|
+
const seat = el('div', 'seat'); seat.dataset.state = 'idle';
|
|
411
|
+
seat.style.setProperty('--seat', a.color || 'var(--primary)');
|
|
412
|
+
const av = el('div', 'seat-av'); av.textContent = a.avatar || '•';
|
|
413
|
+
const badge = el('div', 'seat-badge'); badge.textContent = '0'; badge.title = 'votes';
|
|
414
|
+
av.appendChild(badge);
|
|
415
|
+
const nm = el('div', 'seat-name'); nm.textContent = a.name || a.id;
|
|
416
|
+
const st = el('div', 'seat-state'); st.textContent = 'waiting';
|
|
417
|
+
seat.appendChild(av); seat.appendChild(nm); seat.appendChild(st);
|
|
418
|
+
if(a.id==='chairman'){seat.dataset.chairman='true';st.textContent='standby';}
|
|
419
|
+
if(a.model||a.role){const details=el('div','seat-details');const model=el('strong');model.textContent=a.model||'Session model (inherit)';const role=el('span');role.textContent=a.role||'';details.append(model,role);seat.appendChild(details);}
|
|
420
|
+
bench.appendChild(seat);
|
|
421
|
+
benchSeats[a.id] = { seat, badge, state: st, votes: 0 };
|
|
422
|
+
});
|
|
423
|
+
bench.classList.add('active');
|
|
424
|
+
}
|
|
425
|
+
function seatState(id, state, label) {
|
|
426
|
+
const s = benchSeats[id]; if (!s) return;
|
|
427
|
+
s.seat.dataset.state = state;
|
|
428
|
+
if (label) s.state.textContent = label;
|
|
429
|
+
}
|
|
430
|
+
function bumpSeatVote(id) {
|
|
431
|
+
const s = benchSeats[id]; if (!s) return;
|
|
432
|
+
s.votes += 1; s.badge.textContent = s.votes; s.badge.classList.add('has');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function startAgent(meta) {
|
|
436
|
+
dropWelcome();
|
|
437
|
+
const wrap = el('div', 'agent streaming');
|
|
438
|
+
wrap.style.setProperty('--seat', meta.color || 'var(--primary)');
|
|
439
|
+
const who = el('div', 'who');
|
|
440
|
+
const av = el('span', 'avatar'); av.textContent = meta.avatar || '•';
|
|
441
|
+
const nm = el('span'); nm.textContent = meta.name || meta.id;
|
|
442
|
+
const tag = el('span', 'who-tag'); tag.textContent = 'independent answer';
|
|
443
|
+
who.appendChild(av); who.appendChild(nm); who.appendChild(tag);
|
|
444
|
+
const body = el('div', 'body');
|
|
445
|
+
wrap.appendChild(who); wrap.appendChild(body);
|
|
446
|
+
messages.appendChild(wrap);
|
|
447
|
+
agentEls[meta.id] = { wrap, body, buf: '' };
|
|
448
|
+
seatState(meta.id, 'speaking', 'answering…');
|
|
449
|
+
scrollDown(true);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function deltaAgent(id, text) {
|
|
453
|
+
const a = agentEls[id]; if (!a) return;
|
|
454
|
+
a.buf += text;
|
|
455
|
+
a.body.innerHTML = marked.parse(a.buf);
|
|
456
|
+
highlight(a.body);
|
|
457
|
+
scrollDown();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function finishAgent(id, text) {
|
|
461
|
+
const a = agentEls[id]; if (!a) return;
|
|
462
|
+
a.wrap.classList.remove('streaming');
|
|
463
|
+
if (text) { a.body.innerHTML = marked.parse(text); highlight(a.body); }
|
|
464
|
+
seatState(id, 'answered', 'answered');
|
|
465
|
+
scrollDown();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function agentSearch(id, query) {
|
|
469
|
+
const a = agentEls[id]; if (!a) return;
|
|
470
|
+
const chip = el('div', 'search-chip');
|
|
471
|
+
chip.textContent = '🔍 searched: ' + (query || '…');
|
|
472
|
+
a.wrap.insertBefore(chip, a.body);
|
|
473
|
+
scrollDown();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* Each member casts ONE vote for the best (non-self) answer. */
|
|
477
|
+
function renderVote(ev) {
|
|
478
|
+
const vm = agentMeta[ev.voter] || {};
|
|
479
|
+
const cm = ev.choice ? (agentMeta[ev.choice] || {}) : {};
|
|
480
|
+
const row = el('div', 'vote-row');
|
|
481
|
+
row.style.setProperty('--seat', vm.color || 'var(--primary)');
|
|
482
|
+
const voter = el('span', 'v-voter');
|
|
483
|
+
voter.textContent = (vm.avatar ? vm.avatar + ' ' : '') + (ev.voter_name || ev.voter);
|
|
484
|
+
const arrow = el('span', 'v-arrow'); arrow.textContent = '→';
|
|
485
|
+
const choice = el('span', 'v-choice');
|
|
486
|
+
if (ev.choice) {
|
|
487
|
+
choice.style.color = cm.color || 'var(--fg)';
|
|
488
|
+
choice.textContent = (cm.avatar ? cm.avatar + ' ' : '') + (ev.choice_name || '');
|
|
489
|
+
bumpSeatVote(ev.choice);
|
|
490
|
+
} else {
|
|
491
|
+
choice.textContent = '(abstained)';
|
|
492
|
+
choice.classList.add('muted');
|
|
493
|
+
}
|
|
494
|
+
row.appendChild(voter); row.appendChild(arrow); row.appendChild(choice);
|
|
495
|
+
if (ev.reason) { const why = el('div', 'v-why'); why.textContent = ev.reason; row.appendChild(why); }
|
|
496
|
+
messages.appendChild(row);
|
|
497
|
+
scrollDown();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function renderVerdict(ev) {
|
|
501
|
+
const wm = agentMeta[ev.winner_id] || {};
|
|
502
|
+
const card = el('div', 'verdict');
|
|
503
|
+
card.style.setProperty('--seat', wm.color || 'var(--warning)');
|
|
504
|
+
if (ev.winner_id && benchSeats[ev.winner_id]) {
|
|
505
|
+
seatState(ev.winner_id, 'winner', ev.synthesized ? 'vote leader' : 'chosen');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const h = el('h3');
|
|
509
|
+
h.textContent = ev.synthesized ? 'Final consolidated answer' : 'Council recommendation: ' + (ev.winner_name || 'No consensus');
|
|
510
|
+
card.appendChild(h);
|
|
511
|
+
|
|
512
|
+
const sub = el('div', 'verdict-sub');
|
|
513
|
+
sub.textContent = ev.option_tally ? (ev.tied ? 'Split vote · review the trade-offs' : (ev.votes + ' valid option ballots')) : 'by plurality vote (' + (ev.tally ? (ev.tally[ev.winner_id] || 0) : 0) +
|
|
514
|
+
' of ' + (ev.votes || 0) + ' votes)';
|
|
515
|
+
if (ev.synthesized) { sub.textContent = ev.chairman_name + ' · synthesized from advisor analyses and votes'; }
|
|
516
|
+
card.appendChild(sub);
|
|
517
|
+
|
|
518
|
+
const tally = ev.option_tally || ev.tally || {};
|
|
519
|
+
const max = Math.max(1, ...Object.values(tally));
|
|
520
|
+
const board = el('div', 'leaderboard');
|
|
521
|
+
Object.keys(tally).sort((a, b) => tally[b] - tally[a]).forEach(id => {
|
|
522
|
+
const m = agentMeta[id] || {};
|
|
523
|
+
const r = el('div', 'lb-row' + (id === ev.winner_id ? ' win' : ''));
|
|
524
|
+
r.style.setProperty('--seat', m.color || 'var(--primary)');
|
|
525
|
+
const name = el('div', 'lb-name'); name.textContent = (m.avatar ? m.avatar + ' ' : '') + (m.name || id);
|
|
526
|
+
const bar = el('div', 'lb-bar'); const fill = el('div', 'lb-fill');
|
|
527
|
+
fill.style.width = Math.round((tally[id] / max) * 100) + '%';
|
|
528
|
+
bar.appendChild(fill);
|
|
529
|
+
const pts = el('div', 'lb-pts'); pts.textContent = tally[id] + (tally[id] === 1 ? ' vote' : ' votes');
|
|
530
|
+
r.appendChild(name); r.appendChild(bar); r.appendChild(pts);
|
|
531
|
+
board.appendChild(r);
|
|
532
|
+
});
|
|
533
|
+
card.appendChild(board);
|
|
534
|
+
|
|
535
|
+
if (ev.answer) {
|
|
536
|
+
const lbl = el('div', 'winner-label'); lbl.textContent = ev.synthesized ? 'Chairman synthesis' : 'Winning answer';
|
|
537
|
+
const ans = el('div', 'winner-answer');
|
|
538
|
+
if (ev.option_tally) { ans.textContent = ev.answer; ans.style.whiteSpace = "pre-wrap"; }
|
|
539
|
+
else { ans.innerHTML = marked.parse(ev.answer); highlight(ans); }
|
|
540
|
+
card.appendChild(lbl); card.appendChild(ans);
|
|
541
|
+
}
|
|
542
|
+
card.appendChild(buildApproval(ev));
|
|
543
|
+
messages.appendChild(card);
|
|
544
|
+
scrollDown(true);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/* The council's vote is a recommendation. The round is not settled until you
|
|
548
|
+
say so, so every verdict ends at this gate — and the composer stays open
|
|
549
|
+
afterwards, so the session keeps going as a conversation. */
|
|
550
|
+
const DECISIONS = {
|
|
551
|
+
approve: { mark: '✓', text: 'Settled by you', note: 'recorded as approved' },
|
|
552
|
+
revise: { mark: '↻', text: 'Changes requested', note: 'ask below, the council will build on it' },
|
|
553
|
+
reject: { mark: '✕', text: 'Rejected', note: 'ask below to try a different angle' },
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
function buildApproval(ev) {
|
|
557
|
+
const wrap = el('div', 'approval');
|
|
558
|
+
const lead = el('div', 'approval-lead');
|
|
559
|
+
lead.textContent = 'Your decision';
|
|
560
|
+
wrap.appendChild(lead);
|
|
561
|
+
|
|
562
|
+
const row = el('div', 'approval-row');
|
|
563
|
+
const settle = (decision) => {
|
|
564
|
+
// One decision per round: freeze the row, then report it to the server so
|
|
565
|
+
// it outlives this page. A failure to record must not look like a crash.
|
|
566
|
+
row.remove();
|
|
567
|
+
const d = DECISIONS[decision];
|
|
568
|
+
const state = el('div', 'approval-state');
|
|
569
|
+
state.dataset.decision = decision;
|
|
570
|
+
const t = el('span'); t.textContent = d.mark + ' ' + d.text;
|
|
571
|
+
const n = el('span', 'approval-note'); n.textContent = '· ' + d.note;
|
|
572
|
+
state.appendChild(t); state.appendChild(n);
|
|
573
|
+
wrap.replaceChildren(lead, state);
|
|
574
|
+
|
|
575
|
+
const topic = (lastTopic() || '').replace(/^“|”$/g, '');
|
|
576
|
+
fetch('/api/council/decision', {
|
|
577
|
+
method: 'POST',
|
|
578
|
+
headers: { 'Content-Type': 'application/json' },
|
|
579
|
+
body: JSON.stringify({
|
|
580
|
+
topic: topic,
|
|
581
|
+
decision: decision,
|
|
582
|
+
winner: ev.chairman_name || ev.winner_name || '',
|
|
583
|
+
winner_id: ev.synthesized ? 'chairman' : (ev.winner_id || ''),
|
|
584
|
+
}),
|
|
585
|
+
}).catch(() => {
|
|
586
|
+
const warn = el('div', 'approval-note');
|
|
587
|
+
warn.textContent = '· (could not reach the server to record this)';
|
|
588
|
+
state.appendChild(warn);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
if (decision !== 'approve') { input.focus(); }
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
[['approve', '✓ Approve'], ['revise', '↻ Request changes'], ['reject', '✕ Reject']]
|
|
595
|
+
.forEach(([d, label]) => {
|
|
596
|
+
const b = el('button');
|
|
597
|
+
b.type = 'button';
|
|
598
|
+
b.dataset.decision = d;
|
|
599
|
+
b.textContent = label;
|
|
600
|
+
b.addEventListener('click', () => settle(d));
|
|
601
|
+
row.appendChild(b);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
wrap.appendChild(row);
|
|
605
|
+
return wrap;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/* The topic of the round this verdict belongs to: the banner immediately above
|
|
609
|
+
it, so a click always reports the right question even with several rounds
|
|
610
|
+
on screen. */
|
|
611
|
+
function lastTopic() {
|
|
612
|
+
const banners = messages.querySelectorAll('.topic-banner');
|
|
613
|
+
return banners.length ? banners[banners.length - 1].textContent : '';
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function endRun() {
|
|
617
|
+
if (es) { es.close(); es = null; }
|
|
618
|
+
messages.querySelectorAll('.agent.streaming').forEach(node=>node.classList.remove('streaming'));
|
|
619
|
+
messages.querySelectorAll('.clarification button,.clarification input').forEach(node=>node.disabled=true);
|
|
620
|
+
input.disabled = false; sendBtn.disabled = false; input.focus();
|
|
621
|
+
setRun(false);
|
|
622
|
+
workspaceEnd();
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function convene() {
|
|
626
|
+
const topic = input.value.trim();
|
|
627
|
+
if (!topic || es) return;
|
|
628
|
+
const optionCount = new Set($('decision-options').value.split('\n').map(v=>v.trim()).filter(Boolean)).size;
|
|
629
|
+
if (optionCount === 1 || optionCount > 8) { setStatus('Please provide 2–8 distinct options, or leave options empty.'); return; }
|
|
630
|
+
input.value = ''; input.style.height = 'auto';
|
|
631
|
+
input.disabled = true; sendBtn.disabled = true;
|
|
632
|
+
setRun(true);
|
|
633
|
+
|
|
634
|
+
for (const k in agentEls) delete agentEls[k];
|
|
635
|
+
agentMeta = {};
|
|
636
|
+
|
|
637
|
+
dropWelcome();
|
|
638
|
+
const banner = el('div', 'topic-banner'); banner.textContent = '“' + topic + '”';
|
|
639
|
+
messages.appendChild(banner);
|
|
640
|
+
workspaceBegin();
|
|
641
|
+
setStatus('convening the council…'); scrollDown(true);
|
|
642
|
+
|
|
643
|
+
const params = new URLSearchParams({topic, options:$('decision-options').value, criteria:$('decision-criteria').value,preset:$('council-preset').value});
|
|
644
|
+
es = new EventSource('/api/council?' + params);
|
|
645
|
+
es.addEventListener('question', e => {
|
|
646
|
+
const d = JSON.parse(e.data), card = el('div', 'clarification');
|
|
647
|
+
const title = el('h3'); title.textContent = d.name + ' asks';
|
|
648
|
+
const text = el('p'); text.textContent = d.question;
|
|
649
|
+
const field = el('input'); field.placeholder = 'Your answer or another option'; field.setAttribute('aria-label', d.question);
|
|
650
|
+
card.append(title, text, field);
|
|
651
|
+
const answer = async value => {
|
|
652
|
+
const response = await fetch('/api/council/answer', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({token:d.token, answer:value})}).catch(()=>null);
|
|
653
|
+
if (!response || !response.ok) { setStatus('Could not send answer. Try again.'); return; }
|
|
654
|
+
card.querySelectorAll('button,input').forEach(node=>node.disabled=true);
|
|
655
|
+
text.textContent = d.question + ' — ' + (value || 'Skipped');
|
|
656
|
+
};
|
|
657
|
+
[...(d.choices || []), 'Skip'].forEach(choice=>{const b=el('button');b.textContent=choice;b.onclick=()=>answer(choice==='Skip'?'':choice);card.appendChild(b);});
|
|
658
|
+
const submit=el('button');submit.textContent='Send answer';submit.onclick=()=>answer(field.value);card.appendChild(submit);
|
|
659
|
+
messages.appendChild(card); setStatus('waiting for your clarification…'); scrollDown(true);
|
|
660
|
+
});
|
|
661
|
+
es.addEventListener('question_answered', () => {messages.querySelectorAll('.clarification button,.clarification input').forEach(node=>node.disabled=true);});
|
|
662
|
+
|
|
663
|
+
es.addEventListener('council_start', e => {
|
|
664
|
+
const d = JSON.parse(e.data);
|
|
665
|
+
buildBench([...(d.agents || []),...(d.chairman?[d.chairman]:[])]);
|
|
666
|
+
workspaceTotal=(d.agents||[]).length;updateWorkspaceMetrics();workspaceStage('analysis','Advisors are evaluating independently…');
|
|
667
|
+
setStatus('the council deliberates independently…');
|
|
668
|
+
});
|
|
669
|
+
es.addEventListener('agent_start', e => {
|
|
670
|
+
const d = JSON.parse(e.data);
|
|
671
|
+
agentMeta[d.id] = d; startAgent(d);
|
|
672
|
+
setStatus((d.name || 'an advisor') + ' is forming an answer…');
|
|
673
|
+
});
|
|
674
|
+
es.addEventListener('agent_delta', e => { const d = JSON.parse(e.data); deltaAgent(d.id, d.text); });
|
|
675
|
+
es.addEventListener('agent_tool', e => { const d = JSON.parse(e.data); agentSearch(d.id, d.query); });
|
|
676
|
+
es.addEventListener('agent_done', e => { const d = JSON.parse(e.data); finishAgent(d.id, d.text); workspaceAnswered.add(d.id);updateWorkspaceMetrics(); });
|
|
677
|
+
es.addEventListener('vote_start', e => {
|
|
678
|
+
workspaceStage('voting','Collecting peer reviews and ballots…');
|
|
679
|
+
addPhase('The Vote', JSON.parse(e.data).options ? 'each advisor votes for a decision option' : 'each advisor casts one vote for the best answer');
|
|
680
|
+
Object.keys(benchSeats).forEach(id => {if(id!=='chairman'&&benchSeats[id].seat.dataset.state!=='failed')seatState(id, 'voting', 'voting…');});
|
|
681
|
+
setStatus('the council is voting…');
|
|
682
|
+
});
|
|
683
|
+
es.addEventListener('vote', e => { const d=JSON.parse(e.data);renderVote(d);seatState(d.voter,'answered',d.choice?'voted':'abstained');if(d.choice)workspaceVotes++;updateWorkspaceMetrics(); });
|
|
684
|
+
es.addEventListener('chairman_start', e => {
|
|
685
|
+
const d=JSON.parse(e.data);workspaceStage('synthesis','Resolving disagreement and synthesizing…');messages.querySelectorAll('.synthesis-wait').forEach(node=>node.remove()); addPhase('Chairman synthesis', 'weighing agreement, disagreement, and evidence');
|
|
686
|
+
startAgent({id:'chairman',name:d.name,avatar:'⚖',color:'var(--accent)'});agentEls.chairman.wrap.classList.add('chairman-stream');seatState('chairman','speaking','synthesizing…');
|
|
687
|
+
agentEls.chairman.wrap.querySelector('.who-tag').textContent='consensus synthesis';
|
|
688
|
+
setStatus('the chairman is consolidating the council…');
|
|
689
|
+
});
|
|
690
|
+
es.addEventListener('chairman_delta', e => {deltaAgent('chairman',JSON.parse(e.data).text);});
|
|
691
|
+
es.addEventListener('vote_result', e => {const d=JSON.parse(e.data);addPhase('Ballots collected', d.tied ? 'Split vote · the chairman will examine the disagreement' : d.votes+' valid votes · awaiting chairman synthesis');});
|
|
692
|
+
es.addEventListener('agent_failed', e => {const d=JSON.parse(e.data);finishAgent(d.id,'Advisor unavailable for this round.');seatState(d.id,'failed','unavailable');});
|
|
693
|
+
es.addEventListener('verdict', e => { workspaceCompleted=true;workspaceStage('complete','Consolidated answer ready');seatState('chairman','answered','complete');messages.querySelectorAll('.synthesis-wait').forEach(node=>node.remove());if(agentEls.chairman){agentEls.chairman.wrap.remove();delete agentEls.chairman;} addPhase('The Verdict'); renderVerdict(JSON.parse(e.data)); });
|
|
694
|
+
es.addEventListener('council_error', e => {
|
|
695
|
+
let m = 'The council failed.';
|
|
696
|
+
try { m = JSON.parse(e.data).message || m; } catch (_) {}
|
|
697
|
+
addError(m); setStatus('error');workspaceStage('error','Discussion failed · see the message below');
|
|
698
|
+
// endRun(), not setRun(false): an error has to close the stream and
|
|
699
|
+
// re-enable the composer exactly like `done` does. Leaving `es` non-null
|
|
700
|
+
// made convene() return immediately on every later send — the input stayed
|
|
701
|
+
// disabled and follow-up questions silently did nothing, with no clue why.
|
|
702
|
+
endRun();
|
|
703
|
+
});
|
|
704
|
+
es.addEventListener('done', e => { setStatus('the council has spoken'); endRun(); });
|
|
705
|
+
es.onerror = () => {
|
|
706
|
+
if (es) { es.close(); }
|
|
707
|
+
if (!es || es.readyState === EventSource.CLOSED) {
|
|
708
|
+
setStatus('connection closed'); endRun();
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
input.addEventListener('input', () => {
|
|
714
|
+
input.style.height = 'auto';
|
|
715
|
+
input.style.height = Math.min(input.scrollHeight, 180) + 'px';
|
|
716
|
+
});
|
|
717
|
+
input.addEventListener('keydown', e => {
|
|
718
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); convene(); }
|
|
719
|
+
});
|
|
720
|
+
sendBtn.addEventListener('click', convene);
|
|
721
|
+
$('stop-council').addEventListener('click', () => {fetch('/api/council/stop', {method:'POST'}).catch(()=>{});endRun(); setStatus('discussion stopped');});
|
|
722
|
+
|
|
723
|
+
const newThreadBtn = $('new-thread');
|
|
724
|
+
function newThread() {
|
|
725
|
+
if (es) return; // don't reset mid-run
|
|
726
|
+
fetch('/api/council/reset').catch(() => {});
|
|
727
|
+
messages.innerHTML = '';
|
|
728
|
+
bench.innerHTML = ''; bench.classList.remove('active');
|
|
729
|
+
for (const k in agentEls) delete agentEls[k];
|
|
730
|
+
for (const k in benchSeats) delete benchSeats[k];
|
|
731
|
+
agentMeta = {};
|
|
732
|
+
const w = el('div', 'welcome');
|
|
733
|
+
w.innerHTML = '<h2>Fresh Council</h2><div class="divider"></div>' +
|
|
734
|
+
'<p>Prior session cleared. Pose a new question to convene the council.</p>';
|
|
735
|
+
messages.appendChild(w);
|
|
736
|
+
setStatus('ready'); setRun(false);workspaceAnswered.clear();workspaceVotes=0;workspaceElapsed=0;workspaceCompleted=false;workspaceStage('ready','Ready to convene');updateWorkspaceMetrics();loadCouncilPanel();
|
|
737
|
+
input.focus();
|
|
738
|
+
}
|
|
739
|
+
newThreadBtn.addEventListener('click', newThread);
|
|
740
|
+
__WORKSPACE_CONTROLS_SCRIPT__
|
|
741
|
+
</script>
|
|
742
|
+
<script>__WORKSPACE_SCRIPT__</script>
|
|
743
|
+
__COUNCIL_SETTINGS_HTML__
|
|
744
|
+
<script>__COUNCIL_SETTINGS_SCRIPT__</script>
|
|
745
|
+
</body>
|
|
746
|
+
</html>""".replace("__WORKSPACE_STYLE__", WORKSPACE_CSS).replace("__WORKSPACE_SCRIPT__", WORKSPACE_SCRIPT).replace("__COUNCIL_STYLE__", COUNCIL_STYLE).replace("__COUNCIL_SETTINGS_HTML__", COUNCIL_SETTINGS_HTML).replace("__COUNCIL_SETTINGS_SCRIPT__", COUNCIL_SETTINGS_SCRIPT).replace("__WORKSPACE_CONTROLS_STYLE__", WORKSPACE_CONTROLS_STYLE).replace("__WORKSPACE_CONTROLS_HTML__", WORKSPACE_CONTROLS_HTML).replace("__WORKSPACE_CONTROLS_SCRIPT__", WORKSPACE_CONTROLS_SCRIPT).replace("__WORKSPACE_METRICS_HTML__", WORKSPACE_METRICS_HTML)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
# ---------------------------------------------------------------------------
|
|
750
|
+
# HTTP request handler
|
|
751
|
+
# ---------------------------------------------------------------------------
|
|
752
|
+
|
|
753
|
+
class _ChatHandler(BaseHTTPRequestHandler):
|
|
754
|
+
"""HTTP request handler for the council server."""
|
|
755
|
+
|
|
756
|
+
def log_message(self, format, *args): # noqa: A002 - signature from stdlib
|
|
757
|
+
"""Silence the default stderr logging; we use Nova's console."""
|
|
758
|
+
|
|
759
|
+
def do_GET(self):
|
|
760
|
+
parsed = urlparse(self.path)
|
|
761
|
+
if parsed.path == "/":
|
|
762
|
+
self._serve_html()
|
|
763
|
+
elif parsed.path == "/api/council":
|
|
764
|
+
qs = parse_qs(parsed.query)
|
|
765
|
+
topic = (qs.get("topic") or [""])[0]
|
|
766
|
+
self._stream_council(topic)
|
|
767
|
+
elif parsed.path == "/api/council/panel":
|
|
768
|
+
from novacode_cli.council_settings import load_settings
|
|
769
|
+
from novacode_cli.council_workspace import apply_web_preset, panel_descriptor
|
|
770
|
+
try:
|
|
771
|
+
preset = parse_qs(parsed.query).get("preset", ["saved"])[0]
|
|
772
|
+
self._settings_reply(200, panel_descriptor(apply_web_preset(load_settings(), preset)))
|
|
773
|
+
except ValueError as exc:
|
|
774
|
+
self._settings_reply(400, {"error": str(exc)})
|
|
775
|
+
except Exception:
|
|
776
|
+
self._settings_reply(500, {"error": "Could not load the council panel"})
|
|
777
|
+
elif parsed.path == "/api/council/settings":
|
|
778
|
+
self._council_settings(save=False)
|
|
779
|
+
elif parsed.path == "/api/council/reset":
|
|
780
|
+
self._reset_history()
|
|
781
|
+
else:
|
|
782
|
+
self.send_response(404)
|
|
783
|
+
self.end_headers()
|
|
784
|
+
|
|
785
|
+
def _reset_history(self) -> None:
|
|
786
|
+
"""Clear the council's cross-round history and decisions (fresh thread)."""
|
|
787
|
+
global _council_history, _council_decisions
|
|
788
|
+
with _run_lock:
|
|
789
|
+
_council_history = []
|
|
790
|
+
_council_decisions = []
|
|
791
|
+
self.send_response(204)
|
|
792
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
793
|
+
self.end_headers()
|
|
794
|
+
|
|
795
|
+
def do_POST(self):
|
|
796
|
+
"""Record the user's decision on a round."""
|
|
797
|
+
parsed = urlparse(self.path)
|
|
798
|
+
if parsed.path == "/api/council/settings":
|
|
799
|
+
self._council_settings(save=True)
|
|
800
|
+
return
|
|
801
|
+
if parsed.path == "/api/council/stop":
|
|
802
|
+
with _pending_lock:
|
|
803
|
+
for producer in _producers:
|
|
804
|
+
producer.cancel()
|
|
805
|
+
self._decision_reply(204)
|
|
806
|
+
return
|
|
807
|
+
if parsed.path == "/api/council/answer":
|
|
808
|
+
try:
|
|
809
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
810
|
+
if not 0 < length <= _MAX_DECISION_BODY:
|
|
811
|
+
raise ValueError()
|
|
812
|
+
payload = json.loads(self.rfile.read(length))
|
|
813
|
+
token, answer = payload.get("token"), payload.get("answer")
|
|
814
|
+
if not isinstance(token, str) or not isinstance(answer, str) or len(answer) > 4000:
|
|
815
|
+
raise ValueError()
|
|
816
|
+
with _pending_lock:
|
|
817
|
+
target = _pending_answers.get(token)
|
|
818
|
+
if target is None:
|
|
819
|
+
self._decision_reply(409)
|
|
820
|
+
return
|
|
821
|
+
target.put_nowait(answer.strip() or "Skipped; state assumptions explicitly.")
|
|
822
|
+
self._decision_reply(204)
|
|
823
|
+
except (ValueError, TypeError, AttributeError, queue.Full):
|
|
824
|
+
self._decision_reply(400)
|
|
825
|
+
return
|
|
826
|
+
if parsed.path != "/api/council/decision":
|
|
827
|
+
self.send_response(404)
|
|
828
|
+
self.send_header("Content-Length", "0")
|
|
829
|
+
self.end_headers()
|
|
830
|
+
return
|
|
831
|
+
self._record_decision()
|
|
832
|
+
|
|
833
|
+
def _council_settings(self, *, save: bool) -> None:
|
|
834
|
+
"""Share the inline editor's validated settings, never credentials."""
|
|
835
|
+
from novacode_cli.council_web_settings import save_web_settings, settings_catalog
|
|
836
|
+
|
|
837
|
+
status = 200
|
|
838
|
+
try:
|
|
839
|
+
if save:
|
|
840
|
+
origin = self.headers.get("Origin")
|
|
841
|
+
if origin and urlparse(origin).netloc != self.headers.get("Host"):
|
|
842
|
+
self._settings_reply(403, {"error": "Save settings from the Council page."})
|
|
843
|
+
return
|
|
844
|
+
if self.headers.get_content_type() != "application/json":
|
|
845
|
+
raise ValueError("Settings require application/json")
|
|
846
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
847
|
+
if not 0 < length <= 512 * 1024:
|
|
848
|
+
raise ValueError("Settings body must be between 1 and 524288 bytes")
|
|
849
|
+
raw = json.loads(self.rfile.read(length))
|
|
850
|
+
with _settings_lock:
|
|
851
|
+
result = {"settings": save_web_settings(raw)}
|
|
852
|
+
else:
|
|
853
|
+
with _settings_lock:
|
|
854
|
+
result = settings_catalog()
|
|
855
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
856
|
+
status, result = 400, {"error": str(exc)}
|
|
857
|
+
except Exception:
|
|
858
|
+
logger.exception("Could not access Council settings")
|
|
859
|
+
status, result = 500, {"error": "Could not read or save Council settings. Check Nova's configuration file permissions."}
|
|
860
|
+
self._settings_reply(status, result)
|
|
861
|
+
|
|
862
|
+
def _settings_reply(self, status: int, data: dict) -> None:
|
|
863
|
+
payload = json.dumps(data).encode("utf-8")
|
|
864
|
+
self.send_response(status)
|
|
865
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
866
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
867
|
+
self.send_header("Cache-Control", "no-store")
|
|
868
|
+
self.end_headers()
|
|
869
|
+
try:
|
|
870
|
+
self.wfile.write(payload)
|
|
871
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
872
|
+
pass
|
|
873
|
+
|
|
874
|
+
def _record_decision(self) -> None:
|
|
875
|
+
"""Store one ``{"topic", "decision", ...}`` decision, bounded.
|
|
876
|
+
|
|
877
|
+
Best-effort and never fatal: the page has already rendered the settled
|
|
878
|
+
state locally, so a rejected write is a lost record, not a broken UI.
|
|
879
|
+
"""
|
|
880
|
+
try:
|
|
881
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
882
|
+
except (TypeError, ValueError):
|
|
883
|
+
length = 0
|
|
884
|
+
if length <= 0 or length > _MAX_DECISION_BODY:
|
|
885
|
+
self._decision_reply(400)
|
|
886
|
+
return
|
|
887
|
+
|
|
888
|
+
try:
|
|
889
|
+
raw = self.rfile.read(length)
|
|
890
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
891
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
892
|
+
self._decision_reply(400)
|
|
893
|
+
return
|
|
894
|
+
if not isinstance(payload, dict):
|
|
895
|
+
self._decision_reply(400)
|
|
896
|
+
return
|
|
897
|
+
|
|
898
|
+
decision = str(payload.get("decision") or "")
|
|
899
|
+
if decision not in DECISION_KEYS:
|
|
900
|
+
self._decision_reply(400)
|
|
901
|
+
return
|
|
902
|
+
|
|
903
|
+
entry = {
|
|
904
|
+
"topic": str(payload.get("topic") or "")[:500],
|
|
905
|
+
"decision": decision,
|
|
906
|
+
"winner": str(payload.get("winner") or "")[:200],
|
|
907
|
+
"winner_id": str(payload.get("winner_id") or "")[:100],
|
|
908
|
+
"at": time.time(),
|
|
909
|
+
}
|
|
910
|
+
global _council_decisions
|
|
911
|
+
with _run_lock:
|
|
912
|
+
_council_decisions.append(entry)
|
|
913
|
+
if len(_council_decisions) > _MAX_KEPT_ROUNDS:
|
|
914
|
+
del _council_decisions[:-_MAX_KEPT_ROUNDS]
|
|
915
|
+
self._decision_reply(204)
|
|
916
|
+
|
|
917
|
+
def _decision_reply(self, status: int) -> None:
|
|
918
|
+
self.send_response(status)
|
|
919
|
+
self.send_header("Content-Length", "0")
|
|
920
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
921
|
+
self.end_headers()
|
|
922
|
+
|
|
923
|
+
def _serve_html(self):
|
|
924
|
+
html = _make_chat_html()
|
|
925
|
+
self.send_response(200)
|
|
926
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
927
|
+
self.send_header("Cache-Control", "no-cache")
|
|
928
|
+
self.end_headers()
|
|
929
|
+
try:
|
|
930
|
+
self.wfile.write(html.encode("utf-8"))
|
|
931
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
932
|
+
pass
|
|
933
|
+
|
|
934
|
+
# ---- SSE council stream ----
|
|
935
|
+
|
|
936
|
+
def _stream_council(self, topic: str) -> None:
|
|
937
|
+
"""Run a council over *topic* and stream it to the browser as SSE."""
|
|
938
|
+
self.send_response(200)
|
|
939
|
+
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
940
|
+
self.send_header("Cache-Control", "no-cache")
|
|
941
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
942
|
+
self.send_header("Connection", "keep-alive")
|
|
943
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
944
|
+
self.end_headers()
|
|
945
|
+
|
|
946
|
+
loop = _main_loop
|
|
947
|
+
if loop is None or not loop.is_running():
|
|
948
|
+
self._sse("council_error", {"message": "Agent event loop is not running"})
|
|
949
|
+
self._sse("done", {})
|
|
950
|
+
return
|
|
951
|
+
if not topic.strip():
|
|
952
|
+
self._sse("council_error", {"message": "A topic is required"})
|
|
953
|
+
self._sse("done", {})
|
|
954
|
+
return
|
|
955
|
+
if not _run_lock.acquire(blocking=False):
|
|
956
|
+
self._sse("council_error", {"message": "The council is already in session"})
|
|
957
|
+
self._sse("done", {})
|
|
958
|
+
return
|
|
959
|
+
|
|
960
|
+
try:
|
|
961
|
+
self._pump_council(topic, loop)
|
|
962
|
+
finally:
|
|
963
|
+
_run_lock.release()
|
|
964
|
+
|
|
965
|
+
def _pump_council(self, topic: str, loop: asyncio.AbstractEventLoop) -> None:
|
|
966
|
+
"""Bridge the async council generator (on *loop*) to this thread as SSE.
|
|
967
|
+
|
|
968
|
+
Carries prior rounds in so the advisors can follow up, and records this
|
|
969
|
+
round into the shared history once it completes.
|
|
970
|
+
"""
|
|
971
|
+
q: queue.Queue[Any] = queue.Queue(maxsize=2048)
|
|
972
|
+
parameters = parse_qs(urlparse(self.path).query)
|
|
973
|
+
options = list(dict.fromkeys(o.strip()[:200] for o in parameters.get("options", [""])[0].split("\n") if o.strip()))[:8]
|
|
974
|
+
criteria = parameters.get("criteria", [""])[0][:2000]
|
|
975
|
+
tokens: list[str] = []
|
|
976
|
+
async def ask(event):
|
|
977
|
+
token = event["token"]
|
|
978
|
+
with _pending_lock:
|
|
979
|
+
answers = _pending_answers[token]
|
|
980
|
+
try:
|
|
981
|
+
deadline = time.monotonic() + 240
|
|
982
|
+
while time.monotonic() < deadline:
|
|
983
|
+
try:
|
|
984
|
+
return answers.get_nowait()
|
|
985
|
+
except queue.Empty:
|
|
986
|
+
await asyncio.sleep(.1)
|
|
987
|
+
return "No response before timeout; state assumptions explicitly."
|
|
988
|
+
finally:
|
|
989
|
+
with _pending_lock:
|
|
990
|
+
_pending_answers.pop(token, None)
|
|
991
|
+
history = list(_council_history) # snapshot for this run
|
|
992
|
+
|
|
993
|
+
async def enqueue(item):
|
|
994
|
+
while q.full():
|
|
995
|
+
await asyncio.sleep(.02)
|
|
996
|
+
q.put_nowait(item)
|
|
997
|
+
|
|
998
|
+
async def _produce() -> None:
|
|
999
|
+
from novacode_cli.council import get_council_model, run_council
|
|
1000
|
+
from novacode_cli.council_settings import load_settings, resolve_models
|
|
1001
|
+
|
|
1002
|
+
try:
|
|
1003
|
+
model = await asyncio.to_thread(get_council_model)
|
|
1004
|
+
settings = await asyncio.to_thread(load_settings)
|
|
1005
|
+
from novacode_cli.council_workspace import apply_web_preset, panel_descriptor
|
|
1006
|
+
settings = apply_web_preset(settings, parameters.get("preset", ["saved"])[0])
|
|
1007
|
+
models = await asyncio.to_thread(resolve_models, settings, model, debate=True)
|
|
1008
|
+
from novacode_cli.council_consensus import resolve_chairman
|
|
1009
|
+
chairman_model = await asyncio.to_thread(resolve_chairman, settings, model, models)
|
|
1010
|
+
except Exception as exc: # noqa: BLE001
|
|
1011
|
+
await enqueue(("council_error", {"message": f"Model unavailable: {exc}"}))
|
|
1012
|
+
await enqueue(_STREAM_END)
|
|
1013
|
+
return
|
|
1014
|
+
|
|
1015
|
+
try:
|
|
1016
|
+
kwargs = dict(personas=settings.personas(debate=True), history=history, member_models=models)
|
|
1017
|
+
if len(options) >= 2:
|
|
1018
|
+
from novacode_cli.council_decision import run_decision
|
|
1019
|
+
stream = run_decision(topic, model, options=options, criteria=criteria, ask=ask, **kwargs)
|
|
1020
|
+
else:
|
|
1021
|
+
stream = run_council(topic, model, **kwargs)
|
|
1022
|
+
from novacode_cli.council_consensus import run_web_consensus
|
|
1023
|
+
stream = run_web_consensus(topic, stream, chairman_model, settings.judge)
|
|
1024
|
+
async for event in stream:
|
|
1025
|
+
if event.get("type") == "council_start":
|
|
1026
|
+
event.update(panel_descriptor(settings))
|
|
1027
|
+
if event.get("type") == "question":
|
|
1028
|
+
event["token"] = secrets.token_urlsafe(24)
|
|
1029
|
+
event = dict(event)
|
|
1030
|
+
etype = event.pop("type")
|
|
1031
|
+
if etype == "question":
|
|
1032
|
+
tokens.append(event["token"])
|
|
1033
|
+
# Register before delivery; the browser may answer immediately.
|
|
1034
|
+
with _pending_lock:
|
|
1035
|
+
_pending_answers[event["token"]] = queue.Queue(maxsize=1)
|
|
1036
|
+
await enqueue((etype, event))
|
|
1037
|
+
except Exception as exc: # noqa: BLE001
|
|
1038
|
+
await enqueue(("council_error", {"message": str(exc)}))
|
|
1039
|
+
finally:
|
|
1040
|
+
# Cancellation must never block the event loop on a full queue.
|
|
1041
|
+
if asyncio.current_task().cancelling():
|
|
1042
|
+
if q.full():
|
|
1043
|
+
q.get_nowait()
|
|
1044
|
+
q.put_nowait(_STREAM_END)
|
|
1045
|
+
else:
|
|
1046
|
+
await enqueue(_STREAM_END)
|
|
1047
|
+
|
|
1048
|
+
producer = asyncio.run_coroutine_threadsafe(_produce(), loop)
|
|
1049
|
+
def producer_finished(future):
|
|
1050
|
+
# Also wake the HTTP thread if cancelled during model setup.
|
|
1051
|
+
if future.cancelled():
|
|
1052
|
+
try:
|
|
1053
|
+
q.put_nowait(_STREAM_END)
|
|
1054
|
+
except queue.Full:
|
|
1055
|
+
pass
|
|
1056
|
+
producer.add_done_callback(producer_finished)
|
|
1057
|
+
with _pending_lock:
|
|
1058
|
+
_producers.add(producer)
|
|
1059
|
+
|
|
1060
|
+
# Accumulate this round from the event stream so we can append it to the
|
|
1061
|
+
# shared history once it completes cleanly.
|
|
1062
|
+
id_to_name: dict[str, str] = {}
|
|
1063
|
+
texts: dict[str, str] = {}
|
|
1064
|
+
order: list[str] = []
|
|
1065
|
+
winner_name: str | None = None
|
|
1066
|
+
completed = False
|
|
1067
|
+
|
|
1068
|
+
last_event = time.monotonic()
|
|
1069
|
+
while True:
|
|
1070
|
+
try:
|
|
1071
|
+
item = q.get(timeout=_HEARTBEAT_SECONDS)
|
|
1072
|
+
except queue.Empty:
|
|
1073
|
+
if time.monotonic() - last_event >= _IDLE_TIMEOUT:
|
|
1074
|
+
self._sse("council_error", {"message": "The council timed out"})
|
|
1075
|
+
self._sse("done", {})
|
|
1076
|
+
producer.cancel()
|
|
1077
|
+
break
|
|
1078
|
+
if not self._sse("heartbeat", {}):
|
|
1079
|
+
producer.cancel()
|
|
1080
|
+
break
|
|
1081
|
+
continue
|
|
1082
|
+
last_event = time.monotonic()
|
|
1083
|
+
if item is _STREAM_END:
|
|
1084
|
+
# Record the round before signalling done, so a follow-up request
|
|
1085
|
+
# (or a test) observing the terminal event sees it persisted.
|
|
1086
|
+
if completed and texts:
|
|
1087
|
+
transcript = [[id_to_name.get(i, i), texts[i]] for i in order if i in texts]
|
|
1088
|
+
_council_history.append(
|
|
1089
|
+
{"topic": topic, "transcript": transcript, "winner": winner_name}
|
|
1090
|
+
)
|
|
1091
|
+
# Bounded: the prompt only carries the last few rounds
|
|
1092
|
+
# (council._MAX_HISTORY_ROUNDS), but the list itself grew
|
|
1093
|
+
# forever — a long-lived server accumulated every full
|
|
1094
|
+
# transcript it had ever produced. Keep a small margin over
|
|
1095
|
+
# what the prompt uses and drop the rest.
|
|
1096
|
+
if len(_council_history) > _MAX_KEPT_ROUNDS:
|
|
1097
|
+
del _council_history[:-_MAX_KEPT_ROUNDS]
|
|
1098
|
+
self._sse("done", {})
|
|
1099
|
+
break
|
|
1100
|
+
|
|
1101
|
+
etype, data = item
|
|
1102
|
+
if etype == "council_start":
|
|
1103
|
+
for a in data.get("agents", []):
|
|
1104
|
+
id_to_name[a["id"]] = a["name"]
|
|
1105
|
+
order.append(a["id"])
|
|
1106
|
+
elif etype == "agent_done":
|
|
1107
|
+
texts[data["id"]] = data.get("text", "")
|
|
1108
|
+
elif etype == "verdict":
|
|
1109
|
+
winner_name = data.get("chairman_name") or data.get("winner_name")
|
|
1110
|
+
if data.get("synthesized"):
|
|
1111
|
+
id_to_name["chairman"] = data.get("chairman_name", "Chairman")
|
|
1112
|
+
texts["chairman"] = data.get("answer", "")
|
|
1113
|
+
order.append("chairman")
|
|
1114
|
+
completed = True
|
|
1115
|
+
|
|
1116
|
+
if etype == "done":
|
|
1117
|
+
continue # persist history before the single terminal frame
|
|
1118
|
+
if not self._sse(etype, data):
|
|
1119
|
+
producer.cancel() # client disconnected
|
|
1120
|
+
break
|
|
1121
|
+
|
|
1122
|
+
producer.cancel()
|
|
1123
|
+
with _pending_lock:
|
|
1124
|
+
_producers.discard(producer)
|
|
1125
|
+
for token in tokens:
|
|
1126
|
+
_pending_answers.pop(token, None)
|
|
1127
|
+
|
|
1128
|
+
def _sse(self, event: str, data: dict) -> bool:
|
|
1129
|
+
"""Write one SSE frame. Returns False if the client has disconnected."""
|
|
1130
|
+
try:
|
|
1131
|
+
frame = f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
1132
|
+
self.wfile.write(frame.encode("utf-8"))
|
|
1133
|
+
self.wfile.flush()
|
|
1134
|
+
return True
|
|
1135
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
1136
|
+
return False
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
class _QuietThreadingHTTPServer(ThreadingHTTPServer):
|
|
1140
|
+
"""Threading server that doesn't dump a traceback when a client disconnects.
|
|
1141
|
+
|
|
1142
|
+
Browsers (and the SSE client) routinely drop the connection mid-stream — on
|
|
1143
|
+
``done``, on navigation, or on reset — which surfaces as a connection error
|
|
1144
|
+
in the handler thread. That's expected, not a server fault, so we swallow it.
|
|
1145
|
+
"""
|
|
1146
|
+
|
|
1147
|
+
daemon_threads = True
|
|
1148
|
+
|
|
1149
|
+
def handle_error(self, request, client_address) -> None: # noqa: ANN001, ARG002
|
|
1150
|
+
exc = sys.exc_info()[1]
|
|
1151
|
+
if isinstance(exc, (BrokenPipeError, ConnectionError, OSError)):
|
|
1152
|
+
return
|
|
1153
|
+
logger.exception("Council server error handling %s", client_address)
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
# ---------------------------------------------------------------------------
|
|
1157
|
+
# Helpers
|
|
1158
|
+
# ---------------------------------------------------------------------------
|
|
1159
|
+
|
|
1160
|
+
def _port_is_answered(port: int, *, timeout: float = 0.35) -> bool:
|
|
1161
|
+
"""True if something already answers connections on *port*.
|
|
1162
|
+
|
|
1163
|
+
A successful ``bind("localhost", port)`` is NOT proof the port is usable.
|
|
1164
|
+
When another process holds ``0.0.0.0:port`` (Docker Desktop's proxy is the
|
|
1165
|
+
common case, and WSL's relay behaves the same way), binding the narrower
|
|
1166
|
+
``localhost`` address still succeeds, but connections are answered by that
|
|
1167
|
+
process — so /council opened to somebody else's 404
|
|
1168
|
+
(``{"detail":"Not Found"}``) instead of Nova's page.
|
|
1169
|
+
|
|
1170
|
+
Connecting is the only honest test: if the connect succeeds, someone is
|
|
1171
|
+
listening and the port is not ours to take.
|
|
1172
|
+
"""
|
|
1173
|
+
for family, addr in (
|
|
1174
|
+
(socket.AF_INET, ("127.0.0.1", port)),
|
|
1175
|
+
(socket.AF_INET6, ("::1", port)),
|
|
1176
|
+
):
|
|
1177
|
+
try:
|
|
1178
|
+
with socket.socket(family, socket.SOCK_STREAM) as s:
|
|
1179
|
+
s.settimeout(timeout)
|
|
1180
|
+
if s.connect_ex(addr) == 0:
|
|
1181
|
+
return True
|
|
1182
|
+
except OSError:
|
|
1183
|
+
continue # e.g. no IPv6 stack — not evidence of a squatter
|
|
1184
|
+
return False
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _find_available_port(start_port: int = 8000, max_attempts: int = 100) -> int:
|
|
1188
|
+
"""Find a port that is both bindable AND not already being answered."""
|
|
1189
|
+
for port in range(start_port, start_port + max_attempts):
|
|
1190
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
1191
|
+
try:
|
|
1192
|
+
s.bind(("localhost", port))
|
|
1193
|
+
except OSError:
|
|
1194
|
+
continue
|
|
1195
|
+
# Bindable is necessary but not sufficient — see _port_is_answered.
|
|
1196
|
+
if _port_is_answered(port):
|
|
1197
|
+
continue
|
|
1198
|
+
return port
|
|
1199
|
+
raise RuntimeError(
|
|
1200
|
+
f"No available ports in range {start_port}-{start_port + max_attempts}"
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
# ---------------------------------------------------------------------------
|
|
1205
|
+
# Server lifecycle (shared by CLI handler and TUI)
|
|
1206
|
+
# ---------------------------------------------------------------------------
|
|
1207
|
+
|
|
1208
|
+
def want_restart() -> bool:
|
|
1209
|
+
"""Return True if the server should restart (state reset requested)."""
|
|
1210
|
+
return False
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def is_server_running() -> bool:
|
|
1214
|
+
"""Check if the council server is currently running."""
|
|
1215
|
+
return _server is not None
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def get_server_url() -> str | None:
|
|
1219
|
+
"""Return the URL of the running server, or None."""
|
|
1220
|
+
if _server_port is None:
|
|
1221
|
+
return None
|
|
1222
|
+
return f"http://localhost:{_server_port}"
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def set_agent_refs(
|
|
1226
|
+
agent: Any,
|
|
1227
|
+
assistant_id: str | None,
|
|
1228
|
+
session_state: Any,
|
|
1229
|
+
loop: asyncio.AbstractEventLoop,
|
|
1230
|
+
) -> None:
|
|
1231
|
+
"""Store session references for the background HTTP server thread.
|
|
1232
|
+
|
|
1233
|
+
The council itself only needs the event ``loop`` (to run model calls), but we
|
|
1234
|
+
keep the agent/session references for parity with the CLI/TUI call sites.
|
|
1235
|
+
"""
|
|
1236
|
+
global _agent, _assistant_id, _session_state, _main_loop
|
|
1237
|
+
_agent = agent
|
|
1238
|
+
_assistant_id = assistant_id
|
|
1239
|
+
_session_state = session_state
|
|
1240
|
+
_main_loop = loop
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def start_chat_server() -> str:
|
|
1244
|
+
"""Start the HTTP council server in a daemon thread.
|
|
1245
|
+
|
|
1246
|
+
Must have called :func:`set_agent_refs` first.
|
|
1247
|
+
|
|
1248
|
+
Returns:
|
|
1249
|
+
The URL the server is listening on.
|
|
1250
|
+
|
|
1251
|
+
Raises:
|
|
1252
|
+
RuntimeError: If no available port is found.
|
|
1253
|
+
"""
|
|
1254
|
+
global _server, _server_thread, _server_port
|
|
1255
|
+
|
|
1256
|
+
if _server is not None:
|
|
1257
|
+
assert _server_port is not None
|
|
1258
|
+
return f"http://localhost:{_server_port}"
|
|
1259
|
+
|
|
1260
|
+
port = _find_available_port(8000)
|
|
1261
|
+
_server_port = port
|
|
1262
|
+
|
|
1263
|
+
_server = _QuietThreadingHTTPServer(("localhost", port), _ChatHandler)
|
|
1264
|
+
_server_thread = threading.Thread(
|
|
1265
|
+
target=_server.serve_forever, daemon=True, name="chat-http-server"
|
|
1266
|
+
)
|
|
1267
|
+
_server_thread.start()
|
|
1268
|
+
|
|
1269
|
+
return f"http://localhost:{port}"
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def stop_chat_server() -> bool:
|
|
1273
|
+
"""Stop the running council server.
|
|
1274
|
+
|
|
1275
|
+
Returns:
|
|
1276
|
+
True if the server was stopped, False if it wasn't running.
|
|
1277
|
+
"""
|
|
1278
|
+
global _server, _server_thread, _server_port, _council_history, _council_decisions
|
|
1279
|
+
|
|
1280
|
+
if _server is None:
|
|
1281
|
+
return False
|
|
1282
|
+
|
|
1283
|
+
with _pending_lock:
|
|
1284
|
+
for producer in _producers:
|
|
1285
|
+
producer.cancel()
|
|
1286
|
+
_pending_answers.clear()
|
|
1287
|
+
_server.shutdown()
|
|
1288
|
+
_server.server_close()
|
|
1289
|
+
_server = None
|
|
1290
|
+
_server_thread = None
|
|
1291
|
+
_server_port = None
|
|
1292
|
+
_council_history = []
|
|
1293
|
+
_council_decisions = []
|
|
1294
|
+
return True
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
# ---------------------------------------------------------------------------
|
|
1298
|
+
# CLI command handler
|
|
1299
|
+
# ---------------------------------------------------------------------------
|
|
1300
|
+
|
|
1301
|
+
async def handle_chat_command() -> bool:
|
|
1302
|
+
"""Handle the /chat command — start the council web UI (CLI entry point)."""
|
|
1303
|
+
if is_server_running():
|
|
1304
|
+
url = get_server_url()
|
|
1305
|
+
assert url is not None
|
|
1306
|
+
console.print()
|
|
1307
|
+
console.print(f"[green]Council UI already running at [bold]{url}[/bold][/green]")
|
|
1308
|
+
webbrowser.open(url)
|
|
1309
|
+
return True
|
|
1310
|
+
|
|
1311
|
+
url = start_chat_server()
|
|
1312
|
+
console.print()
|
|
1313
|
+
console.print(f"[bold {COLORS['primary']}]== Nova Council ==[/bold {COLORS['primary']}]")
|
|
1314
|
+
console.print(f"[green]Council UI started at [bold]{url}[/bold][/green]")
|
|
1315
|
+
webbrowser.open(url)
|
|
1316
|
+
console.print("[dim] Type /chat stop to stop the server.[/dim]")
|
|
1317
|
+
console.print()
|
|
1318
|
+
return True
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
async def handle_chat_stop_command() -> bool:
|
|
1322
|
+
"""Handle the /chat stop command — stop the council web UI (CLI entry)."""
|
|
1323
|
+
from novacode_cli.commands.share_handler import stop_share
|
|
1324
|
+
|
|
1325
|
+
stop_share("council")
|
|
1326
|
+
if not is_server_running():
|
|
1327
|
+
console.print("[yellow]Council server is not running.[/yellow]")
|
|
1328
|
+
return True
|
|
1329
|
+
|
|
1330
|
+
console.print("[dim]Shutting down council server...[/dim]")
|
|
1331
|
+
stop_chat_server()
|
|
1332
|
+
console.print("[green]Council server stopped.[/green]")
|
|
1333
|
+
return True
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
# ---------------------------------------------------------------------------
|
|
1337
|
+
# Registry
|
|
1338
|
+
# ---------------------------------------------------------------------------
|
|
1339
|
+
|
|
1340
|
+
def register_commands(registry) -> None:
|
|
1341
|
+
"""Register the /council command."""
|
|
1342
|
+
from novacode_cli.commands import CommandContext
|
|
1343
|
+
|
|
1344
|
+
async def _handle(ctx: CommandContext) -> bool:
|
|
1345
|
+
set_agent_refs(
|
|
1346
|
+
ctx.agent,
|
|
1347
|
+
ctx.assistant_id,
|
|
1348
|
+
ctx.session_state,
|
|
1349
|
+
asyncio.get_running_loop(),
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
args = (ctx.cmd_args or "").strip()
|
|
1353
|
+
if args == "stop":
|
|
1354
|
+
return await handle_chat_stop_command()
|
|
1355
|
+
return await handle_chat_command()
|
|
1356
|
+
|
|
1357
|
+
registry.register("council", _handle)
|