novacode-cli 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (691) hide show
  1. novacode_cli/__init__.py +29 -0
  2. novacode_cli/__main__.py +6 -0
  3. novacode_cli/_lazy_heavy.py +99 -0
  4. novacode_cli/_version.py +3 -0
  5. novacode_cli/agent_stream.py +178 -0
  6. novacode_cli/agents/agent_file.py +250 -0
  7. novacode_cli/agents/agent_mailbox.py +120 -0
  8. novacode_cli/agents/approval_decisions.py +146 -0
  9. novacode_cli/agents/arg_repair.py +196 -0
  10. novacode_cli/agents/async_agents/__init__.py +6 -0
  11. novacode_cli/agents/async_agents/_config.py +213 -0
  12. novacode_cli/agents/async_agents/_mcp_tools.py +192 -0
  13. novacode_cli/agents/async_agents/_specialist.py +84 -0
  14. novacode_cli/agents/async_agents/_test_commands.py +89 -0
  15. novacode_cli/agents/async_agents/_tools.py +432 -0
  16. novacode_cli/agents/async_agents/general_purpose.py +42 -0
  17. novacode_cli/agents/async_agents/langgraph.json +9 -0
  18. novacode_cli/agents/async_agents/research.py +22 -0
  19. novacode_cli/agents/async_context.py +303 -0
  20. novacode_cli/agents/async_on_demand.py +52 -0
  21. novacode_cli/agents/async_task_errors.py +75 -0
  22. novacode_cli/agents/async_workspace.py +27 -0
  23. novacode_cli/agents/attribution.py +165 -0
  24. novacode_cli/agents/browser_mode.py +119 -0
  25. novacode_cli/agents/btw_agent.py +71 -0
  26. novacode_cli/agents/caveman.py +352 -0
  27. novacode_cli/agents/caveman_support.py +370 -0
  28. novacode_cli/agents/code_mode.py +774 -0
  29. novacode_cli/agents/code_runtime.py +160 -0
  30. novacode_cli/agents/context_compaction.py +1339 -0
  31. novacode_cli/agents/core_agent.py +2710 -0
  32. novacode_cli/agents/default_subagents/__init__.py +24 -0
  33. novacode_cli/agents/default_subagents/async_subagents.py +296 -0
  34. novacode_cli/agents/default_subagents/builtin/browser-automation-agent.md +110 -0
  35. novacode_cli/agents/default_subagents/builtin/bug-fix-agent.md +22 -0
  36. novacode_cli/agents/default_subagents/builtin/bug-investigation-agent.md +42 -0
  37. novacode_cli/agents/default_subagents/builtin/code-explorer.md +37 -0
  38. novacode_cli/agents/default_subagents/builtin/code-review-agent.md +33 -0
  39. novacode_cli/agents/default_subagents/builtin/dependency-audit-agent.md +34 -0
  40. novacode_cli/agents/default_subagents/builtin/documentation-update-agent.md +46 -0
  41. novacode_cli/agents/default_subagents/builtin/performance-audit-agent.md +41 -0
  42. novacode_cli/agents/default_subagents/builtin/plan-scout-agent.md +64 -0
  43. novacode_cli/agents/default_subagents/builtin/refactoring-agent.md +35 -0
  44. novacode_cli/agents/default_subagents/builtin/refactoring-specialist-agent.md +175 -0
  45. novacode_cli/agents/default_subagents/builtin/security-audit-agent.md +96 -0
  46. novacode_cli/agents/default_subagents/builtin/test-generation-agent.md +33 -0
  47. novacode_cli/agents/default_subagents/builtin/test-runner-agent.md +55 -0
  48. novacode_cli/agents/default_subagents/prompt.py +88 -0
  49. novacode_cli/agents/default_subagents/subagents.py +138 -0
  50. novacode_cli/agents/dream_agent.py +93 -0
  51. novacode_cli/agents/edit_diagnostics.py +362 -0
  52. novacode_cli/agents/edit_fallback.py +62 -0
  53. novacode_cli/agents/handoff.py +166 -0
  54. novacode_cli/agents/invocation.py +152 -0
  55. novacode_cli/agents/model_budget.py +42 -0
  56. novacode_cli/agents/model_retry.py +113 -0
  57. novacode_cli/agents/model_router.py +420 -0
  58. novacode_cli/agents/openai_decisions.py +186 -0
  59. novacode_cli/agents/plan_agent/__init__.py +12 -0
  60. novacode_cli/agents/plan_agent/plan_agent.py +357 -0
  61. novacode_cli/agents/plan_agent/plan_mode_middleware.py +64 -0
  62. novacode_cli/agents/plan_execution.py +237 -0
  63. novacode_cli/agents/ponytail.py +66 -0
  64. novacode_cli/agents/prime.py +274 -0
  65. novacode_cli/agents/provider_caching.py +99 -0
  66. novacode_cli/agents/server_launcher.py +904 -0
  67. novacode_cli/agents/strict_tools.py +384 -0
  68. novacode_cli/agents/task_discipline.py +294 -0
  69. novacode_cli/agents/tool_call_repair.py +290 -0
  70. novacode_cli/agents/tool_offload.py +291 -0
  71. novacode_cli/agents/tool_search.py +806 -0
  72. novacode_cli/agents/tool_search_variants.py +106 -0
  73. novacode_cli/agents/tool_verdicts.py +1194 -0
  74. novacode_cli/agents/user_async_agents.py +312 -0
  75. novacode_cli/artifacts/__init__.py +10 -0
  76. novacode_cli/artifacts/registry.py +278 -0
  77. novacode_cli/artifacts/server.py +426 -0
  78. novacode_cli/assets/Nova.png +0 -0
  79. novacode_cli/assets/nova-avatar.png +0 -0
  80. novacode_cli/audio/__init__.py +301 -0
  81. novacode_cli/audio/capture.py +137 -0
  82. novacode_cli/audio/downloads.py +273 -0
  83. novacode_cli/audio/pipeline.py +391 -0
  84. novacode_cli/audio/playback.py +87 -0
  85. novacode_cli/audio/providers.py +208 -0
  86. novacode_cli/audio/speakable.py +78 -0
  87. novacode_cli/audio/stt.py +132 -0
  88. novacode_cli/audio/stt_deepgram.py +69 -0
  89. novacode_cli/audio/stt_elevenlabs.py +89 -0
  90. novacode_cli/audio/stt_parakeet.py +94 -0
  91. novacode_cli/audio/stt_whistle.py +61 -0
  92. novacode_cli/audio/summarize.py +92 -0
  93. novacode_cli/audio/tts.py +85 -0
  94. novacode_cli/audio/tts_kokoro.py +73 -0
  95. novacode_cli/audio/tts_orpheus.py +180 -0
  96. novacode_cli/audio/tts_pocket.py +131 -0
  97. novacode_cli/audio/tts_sano.py +69 -0
  98. novacode_cli/audio/vad.py +145 -0
  99. novacode_cli/backends/__init__.py +17 -0
  100. novacode_cli/backends/filesystem.py +894 -0
  101. novacode_cli/backends/native_search.py +92 -0
  102. novacode_cli/bootstrap/__init__.py +9 -0
  103. novacode_cli/bootstrap/env_snapshot.py +240 -0
  104. novacode_cli/bootstrap/steering.py +464 -0
  105. novacode_cli/bootstrap/vision_router.py +604 -0
  106. novacode_cli/brand.py +362 -0
  107. novacode_cli/cli_args.py +718 -0
  108. novacode_cli/commands/__init__.py +237 -0
  109. novacode_cli/commands/agents_commands.py +231 -0
  110. novacode_cli/commands/auth_handler.py +40 -0
  111. novacode_cli/commands/browser_use_handler.py +75 -0
  112. novacode_cli/commands/cache_handler.py +32 -0
  113. novacode_cli/commands/chat_handler.py +1357 -0
  114. novacode_cli/commands/ci_fix.py +171 -0
  115. novacode_cli/commands/claude_plan.py +331 -0
  116. novacode_cli/commands/codex_plan.py +195 -0
  117. novacode_cli/commands/commands.py +1113 -0
  118. novacode_cli/commands/create_handler.py +124 -0
  119. novacode_cli/commands/create_server.py +1887 -0
  120. novacode_cli/commands/cron_handler.py +119 -0
  121. novacode_cli/commands/dream_handler.py +203 -0
  122. novacode_cli/commands/effort_handler.py +65 -0
  123. novacode_cli/commands/evolution_handler.py +214 -0
  124. novacode_cli/commands/file_commands.py +381 -0
  125. novacode_cli/commands/handoff_handler.py +68 -0
  126. novacode_cli/commands/hooks_handler.py +269 -0
  127. novacode_cli/commands/init_handler.py +146 -0
  128. novacode_cli/commands/learning_handler.py +160 -0
  129. novacode_cli/commands/lite_mode_handler.py +76 -0
  130. novacode_cli/commands/log_commands.py +363 -0
  131. novacode_cli/commands/mcp_handler.py +29 -0
  132. novacode_cli/commands/model_handler.py +29 -0
  133. novacode_cli/commands/notifications_handler.py +158 -0
  134. novacode_cli/commands/onboard_handler.py +121 -0
  135. novacode_cli/commands/plan_handler.py +440 -0
  136. novacode_cli/commands/planner_usage.py +250 -0
  137. novacode_cli/commands/plugin_install_handler.py +38 -0
  138. novacode_cli/commands/plugins_handler.py +35 -0
  139. novacode_cli/commands/prime_handler.py +455 -0
  140. novacode_cli/commands/prompt_handler.py +149 -0
  141. novacode_cli/commands/provider_usage.py +160 -0
  142. novacode_cli/commands/ralph_events.py +106 -0
  143. novacode_cli/commands/ralph_handler.py +1293 -0
  144. novacode_cli/commands/refine_handler.py +213 -0
  145. novacode_cli/commands/research_handler.py +202 -0
  146. novacode_cli/commands/rootapp_remote.py +83 -0
  147. novacode_cli/commands/router_handler.py +36 -0
  148. novacode_cli/commands/server_commands.py +249 -0
  149. novacode_cli/commands/session_commands.py +203 -0
  150. novacode_cli/commands/share_handler.py +450 -0
  151. novacode_cli/commands/side_commands.py +366 -0
  152. novacode_cli/commands/skill_invoke.py +420 -0
  153. novacode_cli/commands/skills_commands.py +48 -0
  154. novacode_cli/commands/trace_handler.py +322 -0
  155. novacode_cli/commands/trello_handler.py +226 -0
  156. novacode_cli/commands/trello_remote.py +177 -0
  157. novacode_cli/commands/trello_server.py +1214 -0
  158. novacode_cli/commands/vision_handler.py +127 -0
  159. novacode_cli/commands/voice_handler.py +447 -0
  160. novacode_cli/commands/webhook_handler.py +134 -0
  161. novacode_cli/commands/wiki_commands.py +227 -0
  162. novacode_cli/compaction.py +753 -0
  163. novacode_cli/computation_cache.py +159 -0
  164. novacode_cli/computer/__init__.py +1 -0
  165. novacode_cli/computer/persistent.py +234 -0
  166. novacode_cli/computer/preview.py +253 -0
  167. novacode_cli/computer/runtime.py +474 -0
  168. novacode_cli/computer/windows_console.py +152 -0
  169. novacode_cli/computer/windows_qemu.py +211 -0
  170. novacode_cli/config/browser_mode.py +159 -0
  171. novacode_cli/config/catalog_models.py +95 -0
  172. novacode_cli/config/config.py +1388 -0
  173. novacode_cli/config/credentials.py +540 -0
  174. novacode_cli/config/google_oauth_auth.py +281 -0
  175. novacode_cli/config/model_capabilities.py +343 -0
  176. novacode_cli/config/model_catalog.py +275 -0
  177. novacode_cli/config/model_create.py +1325 -0
  178. novacode_cli/config/model_manager.py +695 -0
  179. novacode_cli/config/nova_config.py +1283 -0
  180. novacode_cli/config/openai_chatgpt_auth.py +384 -0
  181. novacode_cli/config/opencode_gateway.py +25 -0
  182. novacode_cli/config/plan_mode.py +188 -0
  183. novacode_cli/config/provider_auth.py +438 -0
  184. novacode_cli/config/provider_catalog.json +5127 -0
  185. novacode_cli/config/provider_registry.py +38 -0
  186. novacode_cli/config/provider_transports.py +258 -0
  187. novacode_cli/config/role_models.py +175 -0
  188. novacode_cli/context/__init__.py +50 -0
  189. novacode_cli/context/_analysis.py +799 -0
  190. novacode_cli/context/_budget.py +116 -0
  191. novacode_cli/context/_conversation.py +73 -0
  192. novacode_cli/context/_dynamic.py +264 -0
  193. novacode_cli/context/_model_config.py +380 -0
  194. novacode_cli/context/_models_dev.py +99 -0
  195. novacode_cli/context/archives.py +248 -0
  196. novacode_cli/context/estimates.py +65 -0
  197. novacode_cli/context/history.py +17 -0
  198. novacode_cli/context/knowledge_cache.py +292 -0
  199. novacode_cli/context/manager.py +101 -0
  200. novacode_cli/context/observations.py +65 -0
  201. novacode_cli/context/pressure.py +168 -0
  202. novacode_cli/context/provider_accounting.py +109 -0
  203. novacode_cli/context/snapshot.py +493 -0
  204. novacode_cli/context/strategies.py +279 -0
  205. novacode_cli/context/task_progress.py +122 -0
  206. novacode_cli/core/__init__.py +0 -0
  207. novacode_cli/core/agent_loop.py +1821 -0
  208. novacode_cli/core/autonomous_loop.py +277 -0
  209. novacode_cli/core/code_approvals.py +57 -0
  210. novacode_cli/core/input_preparation.py +276 -0
  211. novacode_cli/core/reasoning.py +67 -0
  212. novacode_cli/core/streaming.py +326 -0
  213. novacode_cli/core/subagent_tasks.py +696 -0
  214. novacode_cli/core/subagent_tracking.py +235 -0
  215. novacode_cli/core/tool_results.py +116 -0
  216. novacode_cli/core/verification_loop.py +272 -0
  217. novacode_cli/council.py +595 -0
  218. novacode_cli/council_consensus.py +115 -0
  219. novacode_cli/council_decision.py +157 -0
  220. novacode_cli/council_planner.py +1421 -0
  221. novacode_cli/council_settings.py +208 -0
  222. novacode_cli/council_web_settings.py +41 -0
  223. novacode_cli/council_workspace.py +56 -0
  224. novacode_cli/cowork/__init__.py +10 -0
  225. novacode_cli/cowork/broker_middleware.py +162 -0
  226. novacode_cli/cowork/launcher.py +102 -0
  227. novacode_cli/cowork/policy.py +284 -0
  228. novacode_cli/cowork/ui.py +826 -0
  229. novacode_cli/daemons/__init__.py +1 -0
  230. novacode_cli/daemons/registry.py +342 -0
  231. novacode_cli/desktop_notifications.py +302 -0
  232. novacode_cli/doctor.py +251 -0
  233. novacode_cli/embeddings.py +109 -0
  234. novacode_cli/entrypoint.py +56 -0
  235. novacode_cli/errors/__init__.py +19 -0
  236. novacode_cli/errors/handlers.py +648 -0
  237. novacode_cli/errors/provider_errors.py +249 -0
  238. novacode_cli/errors/taxonomy.py +80 -0
  239. novacode_cli/events.py +179 -0
  240. novacode_cli/file_ops.py +714 -0
  241. novacode_cli/git_safety.py +281 -0
  242. novacode_cli/handoff.py +383 -0
  243. novacode_cli/headless/__init__.py +18 -0
  244. novacode_cli/headless/input.py +58 -0
  245. novacode_cli/headless/output.py +224 -0
  246. novacode_cli/headless/pipe.py +532 -0
  247. novacode_cli/headless/runner.py +461 -0
  248. novacode_cli/hermes/__init__.py +39 -0
  249. novacode_cli/hermes/activation.py +297 -0
  250. novacode_cli/hermes/config.py +129 -0
  251. novacode_cli/hermes/curator.py +199 -0
  252. novacode_cli/hermes/evolution.py +374 -0
  253. novacode_cli/hermes/ledger.py +364 -0
  254. novacode_cli/hermes/memory_tiers.py +969 -0
  255. novacode_cli/hermes/middleware.py +678 -0
  256. novacode_cli/hermes/overview.py +174 -0
  257. novacode_cli/hermes/prompt_evolution.py +503 -0
  258. novacode_cli/hermes/refine_loop.py +643 -0
  259. novacode_cli/hermes/refinement_log.py +190 -0
  260. novacode_cli/hermes/review.py +674 -0
  261. novacode_cli/hermes/skill_debate.py +135 -0
  262. novacode_cli/hermes/skill_discovery.py +450 -0
  263. novacode_cli/hermes/skill_manager.py +335 -0
  264. novacode_cli/hermes/skill_proposals.py +43 -0
  265. novacode_cli/hermes/test_gaming.py +119 -0
  266. novacode_cli/hermes/tracker.py +435 -0
  267. novacode_cli/hermes/tuner.py +206 -0
  268. novacode_cli/hermes/verification_memory.py +134 -0
  269. novacode_cli/hermes/verifier.py +277 -0
  270. novacode_cli/hitl/__init__.py +22 -0
  271. novacode_cli/hitl/interrupts.py +493 -0
  272. novacode_cli/hooks.py +359 -0
  273. novacode_cli/image_utils.py +517 -0
  274. novacode_cli/init/__init__.py +7 -0
  275. novacode_cli/init/events.py +43 -0
  276. novacode_cli/input_utils.py +526 -0
  277. novacode_cli/install_commands.py +87 -0
  278. novacode_cli/integrations/__init__.py +82 -0
  279. novacode_cli/integrations/daytona.py +122 -0
  280. novacode_cli/integrations/docker.py +309 -0
  281. novacode_cli/integrations/host_path.py +111 -0
  282. novacode_cli/integrations/langsmith.py +151 -0
  283. novacode_cli/integrations/modal.py +134 -0
  284. novacode_cli/integrations/runloop.py +130 -0
  285. novacode_cli/integrations/sandbox_factory.py +1388 -0
  286. novacode_cli/integrations/sandbox_registry.py +312 -0
  287. novacode_cli/integrations/workdir_backend.py +589 -0
  288. novacode_cli/main.py +2078 -0
  289. novacode_cli/mcp/__init__.py +73 -0
  290. novacode_cli/mcp/auth.py +189 -0
  291. novacode_cli/mcp/client.py +571 -0
  292. novacode_cli/mcp/code_mode.py +5 -0
  293. novacode_cli/mcp/code_runtime.py +5 -0
  294. novacode_cli/mcp/commands.py +902 -0
  295. novacode_cli/mcp/config.py +406 -0
  296. novacode_cli/mcp/generic.py +128 -0
  297. novacode_cli/mcp/middleware.py +993 -0
  298. novacode_cli/mcp/presets.py +367 -0
  299. novacode_cli/memory/__init__.py +1 -0
  300. novacode_cli/memory/agent_memory.py +1312 -0
  301. novacode_cli/memory/builtin_lessons.py +60 -0
  302. novacode_cli/memory/dream_gates.py +192 -0
  303. novacode_cli/memory/limits.py +59 -0
  304. novacode_cli/memory/parsed_cache.py +106 -0
  305. novacode_cli/memory/recall_stats.py +97 -0
  306. novacode_cli/memory/semantic.py +116 -0
  307. novacode_cli/memory/store.py +335 -0
  308. novacode_cli/memory/wiki.py +274 -0
  309. novacode_cli/migrate.py +185 -0
  310. novacode_cli/onboarding.py +649 -0
  311. novacode_cli/onboarding_check.py +119 -0
  312. novacode_cli/path_approval.py +297 -0
  313. novacode_cli/plan_archive.py +148 -0
  314. novacode_cli/plugins/__init__.py +121 -0
  315. novacode_cli/plugins/claude_plugins.py +538 -0
  316. novacode_cli/plugins/cli.py +168 -0
  317. novacode_cli/plugins/components.py +413 -0
  318. novacode_cli/plugins/hooks.py +356 -0
  319. novacode_cli/plugins/loader.py +317 -0
  320. novacode_cli/plugins/marketplaces.py +199 -0
  321. novacode_cli/plugins/storage.py +70 -0
  322. novacode_cli/prime.py +759 -0
  323. novacode_cli/prime_factory/__init__.py +15 -0
  324. novacode_cli/prime_factory/container.py +198 -0
  325. novacode_cli/prime_factory/controller.py +565 -0
  326. novacode_cli/prime_factory/knowledge.py +261 -0
  327. novacode_cli/prime_factory/publication.py +218 -0
  328. novacode_cli/prime_factory/recovery.py +50 -0
  329. novacode_cli/prime_factory/research.py +370 -0
  330. novacode_cli/prime_factory/runner.py +608 -0
  331. novacode_cli/prime_factory/runtime.py +261 -0
  332. novacode_cli/prime_factory/state.py +201 -0
  333. novacode_cli/prime_factory/store.py +128 -0
  334. novacode_cli/prime_factory/worker.py +129 -0
  335. novacode_cli/prime_factory/workflow.py +899 -0
  336. novacode_cli/prime_factory/workspace.py +317 -0
  337. novacode_cli/prime_memory.py +290 -0
  338. novacode_cli/process_manager.py +585 -0
  339. novacode_cli/project_onboard.py +537 -0
  340. novacode_cli/prompts/System_Prompt_Nova.jinja +23 -0
  341. novacode_cli/prompts/__init__.py +168 -0
  342. novacode_cli/prompts/agent_definitions.jinja +11 -0
  343. novacode_cli/prompts/agent_generation.jinja +125 -0
  344. novacode_cli/prompts/async_agents/general_purpose.jinja +22 -0
  345. novacode_cli/prompts/claude_plan_mode.jinja +12 -0
  346. novacode_cli/prompts/coding_workflow.jinja +18 -0
  347. novacode_cli/prompts/computer_use.jinja +17 -0
  348. novacode_cli/prompts/core_agent_system.jinja +81 -0
  349. novacode_cli/prompts/delegation.jinja +15 -0
  350. novacode_cli/prompts/desktop_power.jinja +4 -0
  351. novacode_cli/prompts/file_paths.jinja +15 -0
  352. novacode_cli/prompts/file_tracker.jinja +23 -0
  353. novacode_cli/prompts/init_agents_md.jinja +56 -0
  354. novacode_cli/prompts/init_exploration.jinja +174 -0
  355. novacode_cli/prompts/init_semantic_orchestrate.jinja +37 -0
  356. novacode_cli/prompts/instruction_scope.jinja +5 -0
  357. novacode_cli/prompts/learnings_extraction.jinja +45 -0
  358. novacode_cli/prompts/longterm_memory.jinja +54 -0
  359. novacode_cli/prompts/mcp.jinja +84 -0
  360. novacode_cli/prompts/memory_consolidation.jinja +40 -0
  361. novacode_cli/prompts/nova_evolution.jinja +73 -0
  362. novacode_cli/prompts/nova_review.jinja +158 -0
  363. novacode_cli/prompts/nova_skill_debate.jinja +36 -0
  364. novacode_cli/prompts/nova_standing_instructions.jinja +26 -0
  365. novacode_cli/prompts/nova_verify.jinja +100 -0
  366. novacode_cli/prompts/onboard_dashboard.jinja +378 -0
  367. novacode_cli/prompts/plan_agent.jinja +87 -0
  368. novacode_cli/prompts/plan_scouts.jinja +5 -0
  369. novacode_cli/prompts/plan_storage.jinja +8 -0
  370. novacode_cli/prompts/planning.jinja +82 -0
  371. novacode_cli/prompts/ponytail/LICENSE +21 -0
  372. novacode_cli/prompts/ponytail/UPSTREAM.json +15 -0
  373. novacode_cli/prompts/ponytail/hooks/ponytail-config.js +175 -0
  374. novacode_cli/prompts/ponytail/hooks/ponytail-instructions.js +98 -0
  375. novacode_cli/prompts/ponytail/skills/ponytail/SKILL.md +52 -0
  376. novacode_cli/prompts/ponytail/skills/ponytail-audit/SKILL.md +103 -0
  377. novacode_cli/prompts/ponytail/skills/ponytail-debt/SKILL.md +44 -0
  378. novacode_cli/prompts/ponytail/skills/ponytail-gain/SKILL.md +52 -0
  379. novacode_cli/prompts/ponytail/skills/ponytail-help/SKILL.md +76 -0
  380. novacode_cli/prompts/ponytail/skills/ponytail-review/SKILL.md +101 -0
  381. novacode_cli/prompts/prompt_evolution.jinja +27 -0
  382. novacode_cli/prompts/ralph_iteration.jinja +75 -0
  383. novacode_cli/prompts/refine_gate.jinja +31 -0
  384. novacode_cli/prompts/refine_plan.jinja +64 -0
  385. novacode_cli/prompts/refine_review.jinja +34 -0
  386. novacode_cli/prompts/research_delivery.jinja +9 -0
  387. novacode_cli/prompts/research_swarm.jinja +130 -0
  388. novacode_cli/prompts/response_style.jinja +5 -0
  389. novacode_cli/prompts/session_summarization.jinja +86 -0
  390. novacode_cli/prompts/skill_creation.jinja +85 -0
  391. novacode_cli/prompts/subagents/fact_checker.jinja +48 -0
  392. novacode_cli/prompts/subagents/financial_analyst.jinja +50 -0
  393. novacode_cli/prompts/subagents/literature_reviewer.jinja +52 -0
  394. novacode_cli/prompts/subagents/market_analyst.jinja +48 -0
  395. novacode_cli/prompts/subagents/research_synthesizer.jinja +57 -0
  396. novacode_cli/prompts/subagents/technical_researcher.jinja +56 -0
  397. novacode_cli/prompts/subagents/web_researcher.jinja +46 -0
  398. novacode_cli/prompts/summarization.jinja +107 -0
  399. novacode_cli/prompts/summarization_answers.jinja +23 -0
  400. novacode_cli/prompts/summarization_questions.jinja +32 -0
  401. novacode_cli/prompts/vision_caption.jinja +21 -0
  402. novacode_cli/prompts/voice_summary.jinja +65 -0
  403. novacode_cli/prompts/wiki_file.jinja +57 -0
  404. novacode_cli/prompts/wiki_ingest.jinja +87 -0
  405. novacode_cli/recovery.py +383 -0
  406. novacode_cli/remote/__init__.py +45 -0
  407. novacode_cli/remote/approvals.py +282 -0
  408. novacode_cli/remote/async_task_clients.py +97 -0
  409. novacode_cli/remote/async_task_watcher.py +240 -0
  410. novacode_cli/remote/bridge.py +905 -0
  411. novacode_cli/remote/command_menu.py +26 -0
  412. novacode_cli/remote/command_ui.py +243 -0
  413. novacode_cli/remote/config.py +199 -0
  414. novacode_cli/remote/discord_bridge.py +530 -0
  415. novacode_cli/remote/discord_format.py +31 -0
  416. novacode_cli/remote/discord_threads.py +225 -0
  417. novacode_cli/remote/images.py +97 -0
  418. novacode_cli/remote/processor.py +601 -0
  419. novacode_cli/remote/rootapp_bot/bot.cjs +16 -0
  420. novacode_cli/remote/rootapp_bot/package-lock.json +2683 -0
  421. novacode_cli/remote/rootapp_bot/package.json +18 -0
  422. novacode_cli/remote/rootapp_bot/transport.cjs +80 -0
  423. novacode_cli/remote/rootapp_bridge.py +370 -0
  424. novacode_cli/remote/rootapp_setup.py +89 -0
  425. novacode_cli/remote/routing.py +103 -0
  426. novacode_cli/remote/scheduler.py +302 -0
  427. novacode_cli/remote/session.py +164 -0
  428. novacode_cli/remote/status.py +411 -0
  429. novacode_cli/remote/streaming.py +188 -0
  430. novacode_cli/remote/telegram_bridge.py +1118 -0
  431. novacode_cli/remote/telegram_format.py +215 -0
  432. novacode_cli/remote/telegram_hub.py +256 -0
  433. novacode_cli/remote/voice_notes.py +120 -0
  434. novacode_cli/remote/webhook_adapters.py +167 -0
  435. novacode_cli/remote/webhook_server.py +154 -0
  436. novacode_cli/rewind.py +150 -0
  437. novacode_cli/rooms/__init__.py +1 -0
  438. novacode_cli/rooms/bridge.py +86 -0
  439. novacode_cli/rooms/cli.py +231 -0
  440. novacode_cli/rooms/nova.py +227 -0
  441. novacode_cli/rooms/runtime.py +299 -0
  442. novacode_cli/rooms/store.py +538 -0
  443. novacode_cli/rooms/workspace.py +94 -0
  444. novacode_cli/security/__init__.py +29 -0
  445. novacode_cli/security/approval_context.py +45 -0
  446. novacode_cli/security/delegated_approval.py +68 -0
  447. novacode_cli/security/local_http.py +85 -0
  448. novacode_cli/security/middleware.py +87 -0
  449. novacode_cli/security/paths.py +35 -0
  450. novacode_cli/security/policy.py +535 -0
  451. novacode_cli/security/policy_writer.py +90 -0
  452. novacode_cli/security/prompt_injection.py +203 -0
  453. novacode_cli/security/remember.py +81 -0
  454. novacode_cli/security/rule_synthesis.py +116 -0
  455. novacode_cli/security/secret_files.py +61 -0
  456. novacode_cli/security/session_allow.py +114 -0
  457. novacode_cli/security/unicode_security.py +516 -0
  458. novacode_cli/security/validator.py +173 -0
  459. novacode_cli/server/__init__.py +9 -0
  460. novacode_cli/server/app.py +632 -0
  461. novacode_cli/server/event_adapter.py +189 -0
  462. novacode_cli/server/session_manager.py +90 -0
  463. novacode_cli/server_main.py +64 -0
  464. novacode_cli/server_runner/dev_server.py +530 -0
  465. novacode_cli/server_runner/test_runner.py +470 -0
  466. novacode_cli/session/__init__.py +1 -0
  467. novacode_cli/session/adapters/__init__.py +240 -0
  468. novacode_cli/session/adapters/claude_code.py +102 -0
  469. novacode_cli/session/adapters/codex.py +77 -0
  470. novacode_cli/session/adapters/harness_native.py +75 -0
  471. novacode_cli/session/checkpoint_coordinator.py +448 -0
  472. novacode_cli/session/context_restore.py +25 -0
  473. novacode_cli/session/history_export.py +228 -0
  474. novacode_cli/session/history_tree.py +174 -0
  475. novacode_cli/session/imported_context.py +267 -0
  476. novacode_cli/session/runtime_identity.py +32 -0
  477. novacode_cli/session/session_persistence.py +1168 -0
  478. novacode_cli/session/session_prompt_builder.py +279 -0
  479. novacode_cli/session/session_restore.py +351 -0
  480. novacode_cli/session/session_summarization.py +20 -0
  481. novacode_cli/session/transcript_journal.py +153 -0
  482. novacode_cli/sessions/__init__.py +11 -0
  483. novacode_cli/sessions/gateway.py +84 -0
  484. novacode_cli/sessions/launch.py +51 -0
  485. novacode_cli/sessions/lease.py +213 -0
  486. novacode_cli/sessions/messaging.py +255 -0
  487. novacode_cli/sessions/protocol.py +216 -0
  488. novacode_cli/sessions/resume.py +31 -0
  489. novacode_cli/sessions/supervisor.py +498 -0
  490. novacode_cli/sessions/tasks.py +73 -0
  491. novacode_cli/sessions/tui_messaging.py +413 -0
  492. novacode_cli/sessions/worker.py +652 -0
  493. novacode_cli/sessions/worktree.py +262 -0
  494. novacode_cli/shell/__init__.py +27 -0
  495. novacode_cli/shell/jobs.py +475 -0
  496. novacode_cli/shell/middleware.py +1568 -0
  497. novacode_cli/shell/os_sandbox.py +205 -0
  498. novacode_cli/shell/output.py +181 -0
  499. novacode_cli/shell/patterns.py +275 -0
  500. novacode_cli/shell/processes.py +87 -0
  501. novacode_cli/shell/utils.py +236 -0
  502. novacode_cli/skills/__init__.py +26 -0
  503. novacode_cli/skills/builtin/handoff/SKILL.md +15 -0
  504. novacode_cli/skills/curation_middleware.py +69 -0
  505. novacode_cli/skills/executable.py +161 -0
  506. novacode_cli/skills/libraries.py +102 -0
  507. novacode_cli/skills/load.py +248 -0
  508. novacode_cli/skills/prune.py +215 -0
  509. novacode_cli/skills/refreshing_middleware.py +896 -0
  510. novacode_cli/skills/retrieval.py +248 -0
  511. novacode_cli/skills/runtime.py +118 -0
  512. novacode_cli/skills/schema.py +254 -0
  513. novacode_cli/skills/skill_creation.py +1549 -0
  514. novacode_cli/skills/skill_lock.py +75 -0
  515. novacode_cli/skills/skill_system_prompt.py +16 -0
  516. novacode_cli/skills/skills_prefs.py +126 -0
  517. novacode_cli/skills/tool_resolver.py +31 -0
  518. novacode_cli/skills/upstream.py +50 -0
  519. novacode_cli/skills/versioning.py +291 -0
  520. novacode_cli/states/Session.py +847 -0
  521. novacode_cli/states/__init__.py +31 -0
  522. novacode_cli/states/slices/__init__.py +17 -0
  523. novacode_cli/states/slices/agent_runtime.py +249 -0
  524. novacode_cli/states/slices/background_tasks.py +21 -0
  525. novacode_cli/states/slices/notifications.py +74 -0
  526. novacode_cli/states/slices/remote_bridge.py +56 -0
  527. novacode_cli/states/slices/ui_settings.py +49 -0
  528. novacode_cli/states/slices/wiki.py +25 -0
  529. novacode_cli/teammates/__init__.py +1 -0
  530. novacode_cli/teammates/controller.py +557 -0
  531. novacode_cli/teammates/graph.py +104 -0
  532. novacode_cli/teammates/registry.py +175 -0
  533. novacode_cli/teammates/service.py +254 -0
  534. novacode_cli/teammates/worker.py +19 -0
  535. novacode_cli/teams/__init__.py +1 -0
  536. novacode_cli/teams/adapters/__init__.py +1 -0
  537. novacode_cli/teams/adapters/container.py +316 -0
  538. novacode_cli/teams/adapters/inprocess.py +206 -0
  539. novacode_cli/teams/adapters/process.py +364 -0
  540. novacode_cli/teams/bootstrap.py +246 -0
  541. novacode_cli/teams/container_git.py +67 -0
  542. novacode_cli/teams/context.py +34 -0
  543. novacode_cli/teams/coordinator.py +815 -0
  544. novacode_cli/teams/gateway.py +200 -0
  545. novacode_cli/teams/host.py +64 -0
  546. novacode_cli/teams/legacy.py +52 -0
  547. novacode_cli/teams/middleware.py +176 -0
  548. novacode_cli/teams/models.py +104 -0
  549. novacode_cli/teams/output.py +47 -0
  550. novacode_cli/teams/protocol.py +50 -0
  551. novacode_cli/teams/recovery.py +80 -0
  552. novacode_cli/teams/scheduler.py +44 -0
  553. novacode_cli/teams/store.py +631 -0
  554. novacode_cli/teams/worker.py +251 -0
  555. novacode_cli/teams/workspace.py +100 -0
  556. novacode_cli/token_utils.py +256 -0
  557. novacode_cli/tools/__init__.py +104 -0
  558. novacode_cli/tools/_shared.py +97 -0
  559. novacode_cli/tools/_types.py +24 -0
  560. novacode_cli/tools/artifact_tools.py +137 -0
  561. novacode_cli/tools/code_search_tools.py +330 -0
  562. novacode_cli/tools/computer_tools.py +77 -0
  563. novacode_cli/tools/cron_tools.py +65 -0
  564. novacode_cli/tools/daemon_tool.py +290 -0
  565. novacode_cli/tools/desktop_tools.py +161 -0
  566. novacode_cli/tools/fetch_tools.py +530 -0
  567. novacode_cli/tools/http_tools.py +279 -0
  568. novacode_cli/tools/inspection_tools.py +107 -0
  569. novacode_cli/tools/job_tools.py +143 -0
  570. novacode_cli/tools/knowledge_tools.py +427 -0
  571. novacode_cli/tools/memory_tools.py +461 -0
  572. novacode_cli/tools/memory_wiki_tools.py +40 -0
  573. novacode_cli/tools/notebook_tools.py +238 -0
  574. novacode_cli/tools/oracle_tool.py +83 -0
  575. novacode_cli/tools/plan_archive_tools.py +80 -0
  576. novacode_cli/tools/plan_mode_tools.py +315 -0
  577. novacode_cli/tools/python_kernel_loop.py +165 -0
  578. novacode_cli/tools/python_kernel_tool.py +222 -0
  579. novacode_cli/tools/reflection_tools.py +34 -0
  580. novacode_cli/tools/scraper_tools.py +377 -0
  581. novacode_cli/tools/skill_tools.py +275 -0
  582. novacode_cli/tools/speak_tool.py +51 -0
  583. novacode_cli/tools/store_memory_tools.py +227 -0
  584. novacode_cli/tools/ui_tools.py +62 -0
  585. novacode_cli/tools/waiter_tools.py +408 -0
  586. novacode_cli/tools/web_tools.py +349 -0
  587. novacode_cli/tools/wiki_tools.py +152 -0
  588. novacode_cli/tools/worktree_tools.py +219 -0
  589. novacode_cli/tracking/__init__.py +10 -0
  590. novacode_cli/tracking/file_tracker.py +826 -0
  591. novacode_cli/tracking/local_metrics.py +170 -0
  592. novacode_cli/tracking/loop_guard.py +507 -0
  593. novacode_cli/tracking/request_metrics.py +60 -0
  594. novacode_cli/tracking/tracing.py +692 -0
  595. novacode_cli/tracking/usage_tree.py +677 -0
  596. novacode_cli/tracking/workspace_anchoring.py +231 -0
  597. novacode_cli/tui/__init__.py +11 -0
  598. novacode_cli/tui/animation_rate.py +9 -0
  599. novacode_cli/tui/animations.py +332 -0
  600. novacode_cli/tui/app.py +19315 -0
  601. novacode_cli/tui/auth_display.py +87 -0
  602. novacode_cli/tui/auth_screens.py +826 -0
  603. novacode_cli/tui/avatar.py +294 -0
  604. novacode_cli/tui/avatar_frames.py +21 -0
  605. novacode_cli/tui/background_tasks.py +35 -0
  606. novacode_cli/tui/browser_settings_screen.py +70 -0
  607. novacode_cli/tui/caveman_screen.py +185 -0
  608. novacode_cli/tui/council_settings_screen.py +182 -0
  609. novacode_cli/tui/council_view.py +535 -0
  610. novacode_cli/tui/decision_settings.py +268 -0
  611. novacode_cli/tui/diff_preview.py +199 -0
  612. novacode_cli/tui/gc_tuning.py +63 -0
  613. novacode_cli/tui/handoff_screen.py +206 -0
  614. novacode_cli/tui/harness.py +244 -0
  615. novacode_cli/tui/history_tree_screen.py +179 -0
  616. novacode_cli/tui/home.py +328 -0
  617. novacode_cli/tui/home_scene.py +94 -0
  618. novacode_cli/tui/icons.py +81 -0
  619. novacode_cli/tui/interface_styles.tcss +327 -0
  620. novacode_cli/tui/matrix3.py +108 -0
  621. novacode_cli/tui/mcp_tools_screen.py +131 -0
  622. novacode_cli/tui/motion.py +178 -0
  623. novacode_cli/tui/output_body.py +136 -0
  624. novacode_cli/tui/output_buffer.py +59 -0
  625. novacode_cli/tui/palette.py +334 -0
  626. novacode_cli/tui/pickers.py +237 -0
  627. novacode_cli/tui/prime_panel.py +506 -0
  628. novacode_cli/tui/provider_signin.py +213 -0
  629. novacode_cli/tui/pty_shell.py +159 -0
  630. novacode_cli/tui/reference_screens.py +444 -0
  631. novacode_cli/tui/resume_picker.py +346 -0
  632. novacode_cli/tui/rooms.py +597 -0
  633. novacode_cli/tui/screens.py +7088 -0
  634. novacode_cli/tui/session_import.py +333 -0
  635. novacode_cli/tui/session_pane.py +242 -0
  636. novacode_cli/tui/session_tab.py +42 -0
  637. novacode_cli/tui/session_transcript.py +118 -0
  638. novacode_cli/tui/shell_output.py +7 -0
  639. novacode_cli/tui/stall_watch.py +174 -0
  640. novacode_cli/tui/statusline.py +40 -0
  641. novacode_cli/tui/statusline_screen.py +91 -0
  642. novacode_cli/tui/teammate_updates.py +162 -0
  643. novacode_cli/tui/teammates.py +548 -0
  644. novacode_cli/tui/terminal.py +289 -0
  645. novacode_cli/tui/terminal_view.py +235 -0
  646. novacode_cli/tui/themes.py +118 -0
  647. novacode_cli/tui/update_notice.py +84 -0
  648. novacode_cli/tui/voice_downloads.py +113 -0
  649. novacode_cli/tui/widgets.py +2038 -0
  650. novacode_cli/tui/windows_input.py +38 -0
  651. novacode_cli/tui/workspace_approval.py +165 -0
  652. novacode_cli/ui/council_web.py +113 -0
  653. novacode_cli/ui/council_workspace.py +72 -0
  654. novacode_cli/ui/cowork_web.py +41 -0
  655. novacode_cli/ui/diff_highlight.py +160 -0
  656. novacode_cli/ui/execution.py +1396 -0
  657. novacode_cli/ui/exit_summary.py +65 -0
  658. novacode_cli/ui/hitl_approval.py +196 -0
  659. novacode_cli/ui/input_preparation.py +2 -0
  660. novacode_cli/ui/interrupt_handlers.py +215 -0
  661. novacode_cli/ui/question_prompt.py +151 -0
  662. novacode_cli/ui/status_phrases.py +157 -0
  663. novacode_cli/ui/stream_preview.py +66 -0
  664. novacode_cli/ui/streaming.py +2 -0
  665. novacode_cli/ui/subagent_tracking.py +2 -0
  666. novacode_cli/ui/ui_elements.py +1585 -0
  667. novacode_cli/ui/web_workspace.py +244 -0
  668. novacode_cli/ui_events.py +299 -0
  669. novacode_cli/update_ui.py +243 -0
  670. novacode_cli/updates.py +489 -0
  671. novacode_cli/utils/backend_patches.py +399 -0
  672. novacode_cli/utils/backend_paths.py +467 -0
  673. novacode_cli/utils/lazy_providers.py +51 -0
  674. novacode_cli/utils/model_info.py +223 -0
  675. novacode_cli/utils/pdf_extraction.py +183 -0
  676. novacode_cli/utils/startup.py +29 -0
  677. novacode_cli/utils/tasks.py +42 -0
  678. novacode_cli/utils/windows.py +9 -0
  679. novacode_cli/vixie/hook_adapter.py +127 -0
  680. novacode_cli/vixie/server.py +375 -0
  681. novacode_cli/wiki/__init__.py +15 -0
  682. novacode_cli/wiki/ask.py +56 -0
  683. novacode_cli/wiki/file.py +71 -0
  684. novacode_cli/wiki/ingest.py +184 -0
  685. novacode_cli/wiki/manager.py +261 -0
  686. novacode_cli-1.2.0.dist-info/METADATA +271 -0
  687. novacode_cli-1.2.0.dist-info/RECORD +691 -0
  688. novacode_cli-1.2.0.dist-info/WHEEL +5 -0
  689. novacode_cli-1.2.0.dist-info/entry_points.txt +2 -0
  690. novacode_cli-1.2.0.dist-info/licenses/LICENSE +21 -0
  691. novacode_cli-1.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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&#10;Buy a service&#10;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">&gt;</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)