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,1821 @@
1
+ """Shared LangGraph agent iteration loop.
2
+
3
+ ``iterate_agent_events`` is the single canonical async generator for driving a
4
+ LangGraph agent stream. It yields :mod:`novacode_cli.ui_events` dataclass
5
+ instances that describe what happened — text, tool calls, interrupts, etc. —
6
+ without coupling to any particular rendering strategy.
7
+
8
+ Two consumers wrap it:
9
+
10
+ * :func:`novacode_cli.agent_stream.run_agent_stream` — forwards events directly
11
+ (for the Textual TUI and test code).
12
+ * :func:`novacode_cli.ui.execution.execute_task` — consumes events and renders
13
+ each to the ``rich`` console.
14
+
15
+ Bug fixes and feature additions belong *here* so both UIs benefit immediately.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import time
23
+ from collections.abc import AsyncIterator
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ from langchain.agents.middleware.human_in_the_loop import HITLRequest
27
+ from langchain_core.messages import HumanMessage, ToolMessage
28
+ from langgraph.errors import GraphInterrupt
29
+ from langgraph.types import Command, Interrupt
30
+ from pydantic import TypeAdapter, ValidationError
31
+
32
+
33
+ from novacode_cli.config.config import COLORS, get_agent_color
34
+ from novacode_cli.file_ops import get_session_file_op_tracker
35
+ from novacode_cli.core.reasoning import reasoning_text
36
+ from novacode_cli.core.input_preparation import (
37
+ build_agent_config,
38
+ get_agent_display_name,
39
+ prepare_input_content,
40
+ )
41
+ from novacode_cli.core.streaming import (
42
+ TOOL_CATEGORIES,
43
+ TOOL_ICONS,
44
+ _could_be_session_intent_heading,
45
+ format_condensed_activity,
46
+ is_internal_context_text,
47
+ looks_like_summarization_output,
48
+ )
49
+ from novacode_cli.tracking.loop_guard import TextRepetitionGuard
50
+ from novacode_cli.tracking.tracing import traced_stream
51
+ from novacode_cli.tracking.usage_tree import scoped_stream
52
+ from novacode_cli.ui import status_phrases
53
+ from novacode_cli.core.subagent_tracking import (
54
+ SubagentTracker,
55
+ get_status_icon,
56
+ )
57
+ from novacode_cli.core.subagent_tasks import (
58
+ TASK_ID_METADATA_KEY,
59
+ task_from_async_entry,
60
+ task_from_custom_event,
61
+ task_from_sync_completion,
62
+ task_from_sync_dispatch,
63
+ )
64
+ from novacode_cli.ui.ui_elements import (
65
+ format_tool_display,
66
+ format_tool_message_content,
67
+ format_tool_result_preview,
68
+ )
69
+ from novacode_cli import ui_events as ev
70
+
71
+ if TYPE_CHECKING:
72
+ from collections.abc import AsyncGenerator
73
+
74
+ _HITL_REQUEST_ADAPTER = TypeAdapter(HITLRequest)
75
+
76
+
77
+ class _RepetitionLoop(Exception):
78
+ """Raised to abort a turn whose model output has started cycling."""
79
+
80
+
81
+ #: Consecutive policy-rejected resumes (no human asked) before the turn is
82
+ #: aborted. Each resume is a fresh model call, so this bounds a runaway loop.
83
+ MAX_AUTO_REJECT_RESUMES = 3
84
+
85
+
86
+ def format_interrupt_notification(kind: str, payload: Any) -> str:
87
+ """Turn a raw interrupt payload into a short, human-readable line for
88
+ notifications (desktop toast, badge, remote) — instead of a dumped dict.
89
+
90
+ tool → ``"shell: docker ps --format …"`` (one line per action request);
91
+ plan/question → a friendly sentence.
92
+ """
93
+ try:
94
+ if kind == "tool":
95
+ reqs = (payload or {}).get("action_requests") or []
96
+ lines: list[str] = []
97
+ for r in reqs:
98
+ name = r.get("name") or "tool"
99
+ args = r.get("args") or {}
100
+ detail = (
101
+ args.get("command")
102
+ or args.get("file_path")
103
+ or args.get("path")
104
+ or args.get("url")
105
+ or r.get("description")
106
+ )
107
+ if not detail and args:
108
+ detail = ", ".join(f"{k}={v}" for k, v in list(args.items())[:3])
109
+ detail = " ".join(str(detail).split()) if detail else "" # collapse whitespace/newlines
110
+ lines.append(f"{name}: {detail}" if detail else str(name))
111
+ msg = " • ".join(lines) or "A tool action needs your approval."
112
+ elif kind == "plan":
113
+ msg = "A plan is ready for your review and approval."
114
+ elif kind == "question":
115
+ q = (payload or {}).get("question") or (payload or {}).get("prompt") or ""
116
+ msg = " ".join(str(q).split()) or "The agent is asking a question."
117
+ else:
118
+ msg = "Nova needs your input."
119
+ except Exception: # noqa: BLE001 — a formatter must never break the turn
120
+ msg = "Nova needs your approval."
121
+ return msg[:220]
122
+
123
+
124
+ def default_interrupt_response(kind: str) -> Any:
125
+ """Safe fallback for an unresolved :class:`~novacode_cli.ui_events.InterruptRequest`.
126
+
127
+ Consumers resolve the request's future in their own coroutines; if that
128
+ handler raises before calling ``set_result``, the agent loop would await the
129
+ future forever. Consumers should resolve with this value in a ``finally`` so
130
+ the turn fails closed (reject) instead of hanging.
131
+ """
132
+ if kind == "tool":
133
+ return {"decisions": [], "any_rejected": True}
134
+ if kind == "plan":
135
+ return {
136
+ "response": {"approved": False, "action": "reject", "feedback": ""},
137
+ "state_update": {},
138
+ }
139
+ # "question" and anything else: an empty response is benign.
140
+ return {}
141
+
142
+
143
+ #: Written into the conversation when the user cancels a turn.
144
+ _CANCEL_NOTICE = "[The previous request was interrupted before it finished]"
145
+
146
+
147
+ async def _record_cancellation(agent: Any, config: Any) -> None:
148
+ """Record a cancelled turn in the agent's history, leaving it well-formed.
149
+
150
+ Cancelling while a tool runs is the common case: the model's tool-calling
151
+ message is already committed, but the tool never reports back. Appending
152
+ the cancel notice straight after it left an assistant message whose
153
+ ``tool_calls`` were followed by a *user* message — which OpenAI-strict
154
+ endpoints reject ("An assistant message with 'tool_calls' must be followed
155
+ by tool messages responding to each 'tool_call_id'"), killing every later
156
+ turn in that conversation until something repaired it.
157
+
158
+ Found in real history: of 514 saved conversations, 5 broke that rule, and
159
+ 4 of the 5 were followed by exactly this notice. So each unanswered call
160
+ now gets an honest "cancelled" result first, then the notice.
161
+
162
+ Only the final message is patched: that is precisely the cancel-mid-tool
163
+ shape, and appending directly after it keeps each result adjacent to its
164
+ call. A dangling call anywhere else is left to RepairToolCallsEachStep,
165
+ which rewrites in place rather than appending.
166
+ """
167
+ from langchain_core.messages import AIMessage
168
+
169
+ messages: list[Any] = [HumanMessage(content=_CANCEL_NOTICE)]
170
+ try:
171
+ snapshot = await agent.aget_state(config)
172
+ history = (getattr(snapshot, "values", None) or {}).get("messages", []) or []
173
+ except Exception: # noqa: BLE001 — recording the notice must not depend on this
174
+ history = []
175
+
176
+ last = history[-1] if history else None
177
+ if isinstance(last, AIMessage) and last.tool_calls:
178
+ answered = {
179
+ getattr(m, "tool_call_id", None) for m in history if getattr(m, "type", "") == "tool"
180
+ }
181
+ results = [
182
+ ToolMessage(
183
+ content="Cancelled by the user before it finished; no result.",
184
+ tool_call_id=call["id"],
185
+ name=call.get("name") or "unknown",
186
+ )
187
+ for call in last.tool_calls
188
+ if call.get("id") and call["id"] not in answered
189
+ ]
190
+ messages = [*results, *messages]
191
+
192
+ await agent.aupdate_state(config=config, values={"messages": messages}, as_node="model")
193
+
194
+
195
+ async def _safe_stream(stream_gen: AsyncIterator[Any]) -> AsyncIterator[Any]:
196
+ """Wraps an async generator and swallows GraphInterrupt.
197
+
198
+ This ensures that when the graph execution is suspended due to an interrupt,
199
+ the resulting GraphInterrupt exception is caught and the stream completes
200
+ normally instead of crashing the iteration loop.
201
+ """
202
+ try:
203
+ async for chunk in stream_gen:
204
+ yield chunk
205
+ except GraphInterrupt:
206
+ pass
207
+
208
+
209
+ def _save_unresumed_plan(approved_plan: tuple[str, bool] | None, session_state: Any) -> str | None:
210
+ """Persist an approved plan whose planner will not be resumed. Best-effort.
211
+
212
+ User-approved → the project's ``.nova/plans/``; approved by Nova itself →
213
+ the global ``~/.nova/plans/``. A planner that passed no inline plan wrote
214
+ its own file under ``.nova/plans/`` (the one place plan mode lets it
215
+ write): that is already the right home for a user-approved plan, and is
216
+ read back so an auto-approved one still reaches the global folder.
217
+ """
218
+ if approved_plan is None:
219
+ return None
220
+ text, auto = approved_plan
221
+ try:
222
+ if not text.strip():
223
+ if not auto:
224
+ return None
225
+ from novacode_cli.ui.interrupt_handlers import resolve_plan_content
226
+
227
+ text = resolve_plan_content(None, session_state)[0] or ""
228
+ from novacode_cli.tools.plan_mode_tools import persist_approved_plan
229
+
230
+ return persist_approved_plan(text, auto_approved=auto)
231
+ except Exception: # noqa: BLE001 — saving a plan must never break the turn
232
+ return None
233
+
234
+
235
+ async def iterate_agent_events(
236
+ user_input: str,
237
+ agent: Any, # noqa: ANN401 - frontend agents include compiled and wrapped graphs.
238
+ assistant_id: str | None,
239
+ session_state: Any, # noqa: ANN401 - TUI, headless, and remote session adapters.
240
+ *,
241
+ backend: Any = None, # noqa: ANN401 - forwards the existing backend interface.
242
+ image_tracker: Any = None, # noqa: ANN401 - forwards the existing tracker interface.
243
+ seen_message_ids: set[str] | None = None,
244
+ skip_file_mentions: bool = False,
245
+ ) -> AsyncGenerator[Any, None]:
246
+ """Drive one turn with usage attribution shared by every frontend."""
247
+ from novacode_cli.prime_factory.runtime import is_factory_request, iterate_factory_events
248
+
249
+ if is_factory_request(user_input, session_state):
250
+ async for event in iterate_factory_events(user_input, session_state, agent=agent):
251
+ yield event
252
+ return
253
+ from novacode_cli.tracking.usage_tree import (
254
+ UsageTree,
255
+ _current_tree,
256
+ get_current_tree,
257
+ persist_usage_tree,
258
+ )
259
+
260
+ previous = get_current_tree()
261
+ tree = previous if previous is not None else UsageTree(session_state.thread_id)
262
+ stream = _iterate_agent_events(
263
+ user_input,
264
+ agent,
265
+ assistant_id,
266
+ session_state,
267
+ backend=backend,
268
+ image_tracker=image_tracker,
269
+ seen_message_ids=seen_message_ids,
270
+ skip_file_mentions=skip_file_mentions,
271
+ legacy_prime=False,
272
+ )
273
+ try:
274
+ while True:
275
+ # A consumer may close this generator from a different task. Bind
276
+ # only while advancing it, and restore the caller before yielding.
277
+ token = _current_tree.set(tree)
278
+ try:
279
+ event = await anext(stream)
280
+ except StopAsyncIteration:
281
+ break
282
+ finally:
283
+ _current_tree.reset(token)
284
+ yield event
285
+ finally:
286
+ token = _current_tree.set(tree)
287
+ try:
288
+ await stream.aclose()
289
+ finally:
290
+ _current_tree.reset(token)
291
+ if previous is None and tree.root.totals()["calls"]:
292
+ await persist_usage_tree(tree)
293
+
294
+
295
+ async def _iterate_agent_events( # noqa: C901, PLR0912, PLR0915
296
+ user_input: str,
297
+ agent,
298
+ assistant_id: str | None,
299
+ session_state,
300
+ *,
301
+ backend=None,
302
+ image_tracker=None,
303
+ seen_message_ids: set[str] | None = None,
304
+ skip_file_mentions: bool = False,
305
+ legacy_prime: bool = True,
306
+ ) -> AsyncGenerator[Any, None]:
307
+ """Run the agent and yield UI events.
308
+
309
+ Yields instances from :mod:`novacode_cli.ui_events`. Terminates with a
310
+ :class:`~novacode_cli.ui_events.Done`, :class:`~novacode_cli.ui_events.Cancelled`,
311
+ or :class:`~novacode_cli.ui_events.Error` event.
312
+ """
313
+ from novacode_cli.agents.ponytail import helper_prompt, is_deactivation_command
314
+ from novacode_cli.config.nova_config import NovaConfig
315
+
316
+ compression_config = await asyncio.to_thread(NovaConfig)
317
+ if (
318
+ compression_config.get_compression_mode() == "ponytail"
319
+ and is_deactivation_command(user_input)
320
+ ):
321
+ try:
322
+ await asyncio.to_thread(compression_config.set_compression_mode, "off")
323
+ except OSError:
324
+ yield ev.Error(message="Could not save compression preference.")
325
+ yield ev.Done(had_response=False)
326
+ return
327
+ command = user_input.split(maxsplit=1)
328
+ if command and command[0].lower() in {
329
+ "/ponytail-" + name for name in ("review", "audit", "debt", "gain", "help")
330
+ }:
331
+ user_input = helper_prompt(user_input)
332
+ loop = asyncio.get_running_loop()
333
+ prime_original_input = user_input
334
+
335
+ if getattr(session_state, "plan_mode_enabled", False):
336
+ directive = (
337
+ "[System Directive: You are entering a new planning phase. "
338
+ "Any previous plan approvals have expired. You are currently restricted to "
339
+ "read-only tools. Design a new plan for the request below, and call `exit_plan_mode(plan=...)` "
340
+ "to request user approval. Do NOT call write_todos or attempt to implement yet.]\n\n"
341
+ )
342
+ if not user_input.startswith("[System Directive:"):
343
+ user_input = directive + user_input
344
+
345
+ active_goal = getattr(session_state, "active_goal", None)
346
+ goal_in_scope = (
347
+ bool(active_goal)
348
+ and getattr(session_state, "goal_status", "active") == "active"
349
+ and getattr(session_state, "goal_thread_id", session_state.thread_id)
350
+ == session_state.thread_id
351
+ )
352
+ if (
353
+ goal_in_scope
354
+ and not user_input.startswith("[GOAL]")
355
+ and not user_input.startswith("[System Directive:")
356
+ ):
357
+ user_input = f"[GOAL] {active_goal}\n\n{user_input}"
358
+
359
+ message_content = await prepare_input_content(
360
+ user_input, image_tracker, skip_file_mentions=skip_file_mentions
361
+ )
362
+
363
+ thread_id = session_state.thread_id
364
+ config = build_agent_config(thread_id, assistant_id)
365
+ from novacode_cli.config.config import settings
366
+ from novacode_cli.session.runtime_identity import RuntimeIdentity
367
+
368
+ project = getattr(agent, "project", None)
369
+ if not isinstance(project, str):
370
+ project = str(settings.get_workspace_root())
371
+ runtime_session = RuntimeIdentity.from_config(project, config, "").session
372
+ team_host = getattr(agent, "team_coordinator", None)
373
+ if team_host is None:
374
+ from novacode_cli.teams.host import TeamHost
375
+
376
+ team_host = next(
377
+ (
378
+ resource
379
+ for resource in getattr(agent, "runtime_resources", ())
380
+ if isinstance(resource, TeamHost)
381
+ ),
382
+ None,
383
+ )
384
+ team_runtime = team_host.bind_session(session_state) if team_host is not None else None
385
+
386
+ agent_display_name = get_agent_display_name(assistant_id)
387
+ agent_colors = (
388
+ get_agent_color(assistant_id)
389
+ if assistant_id and assistant_id != "nova-agent"
390
+ else (COLORS["success"] if assistant_id == "nova-agent" else COLORS["agent"])
391
+ )
392
+
393
+ if seen_message_ids is None:
394
+ seen_message_ids = set()
395
+ seen_before_turn = set(seen_message_ids)
396
+
397
+ file_op_tracker = get_session_file_op_tracker(assistant_id=assistant_id, backend=backend)
398
+ subagent_tracker = SubagentTracker()
399
+
400
+ # Watches the model's own prose for a verbatim-repeat cycle — the failure
401
+ # LoopGuardMiddleware can't see, because no tool is ever called.
402
+ text_guard = TextRepetitionGuard()
403
+
404
+ displayed_tool_ids: set[str] = set()
405
+ tool_call_buffers: dict[str | int, dict] = {}
406
+ streamed_reasoning_ids: set[tuple[str, str | None]] = set()
407
+ tool_call_to_name: dict[str, str] = {}
408
+ pending_text = ""
409
+ current_ai_message_id: str | None = None
410
+ _post_summarization = False
411
+ compaction_status_started = False
412
+ summary_preview_suppressed = False
413
+ preview_started = False
414
+ has_responded = False
415
+ _streamed_pending = False # whether TextDelta(s) were emitted for the buffer
416
+
417
+ captured = ev.UsageUpdate()
418
+ context_shown = 0 # last request size sent to the UI as ContextUsage
419
+ usage_calls: dict[object, tuple[int, int, int, int]] = {}
420
+ compacted = False
421
+ compaction_finished = False
422
+ usage_after_compaction = False
423
+
424
+ # Drain any Nova events queued by the middleware (review cycles, skill
425
+ # activity) into proper ContextMessage events for both UIs.
426
+ def _drain_nova_events() -> list:
427
+ try:
428
+ from novacode_cli.events import nova_event_log
429
+
430
+ events: list[ev.ContextMessage] = []
431
+ for etype, icon, color, msg in nova_event_log.drain(runtime_session):
432
+ events.append(
433
+ ev.ContextMessage(message=msg, event_type=etype, icon=icon, color=color)
434
+ )
435
+ if team_runtime is not None:
436
+ events.extend(team_runtime.drain_events())
437
+ return events
438
+ except Exception:
439
+ return []
440
+
441
+ # Turn boundary: release any held phrases so this turn opens with fresh
442
+ # wording, then hold this one for the rest of the turn. It must be the
443
+ # *sticky* form: the TUI rebuilds its status on every streamed token behind
444
+ # an `if self._activity != <phrase>` guard, and a phrase that re-rolled per
445
+ # call would defeat that guard. Both UIs consume this one message, so a
446
+ # reset here is what gives the console REPL per-turn variety too.
447
+ status_phrases.reset()
448
+ yield ev.StatusUpdate(
449
+ status_phrases.status_line("thinking", agent_display_name, sticky_phrase=True)
450
+ )
451
+ for _ctx in _drain_nova_events():
452
+ yield _ctx
453
+
454
+ def _flush_events() -> list:
455
+ """Finalize buffered prose into events.
456
+
457
+ Returns ``[AssistantMessage]`` to commit, ``[TextDiscard]`` to drop a
458
+ live preview that was suppressed (internal context / dedup), or ``[]``.
459
+ A suppressed library-summarization block also appends a
460
+ ``ContextMessage`` so the retraction is not silent.
461
+ """
462
+ nonlocal pending_text, current_ai_message_id, _post_summarization
463
+ nonlocal has_responded, _streamed_pending
464
+ nonlocal summary_preview_suppressed, preview_started, compaction_status_started
465
+
466
+ def _discard(notice: str | None = None) -> list:
467
+ nonlocal pending_text, current_ai_message_id, _streamed_pending
468
+ pending_text = ""
469
+ current_ai_message_id = None
470
+ had = _streamed_pending
471
+ _streamed_pending = False
472
+ summary_preview_suppressed = False
473
+ preview_started = False
474
+ out: list = [ev.TextDiscard()] if had else []
475
+ if notice:
476
+ out.append(
477
+ ev.ContextMessage(
478
+ message=notice,
479
+ event_type="nova_auto_compact",
480
+ icon="✓",
481
+ color="dim",
482
+ )
483
+ )
484
+ return out
485
+
486
+ if not pending_text.strip():
487
+ return _discard()
488
+
489
+ # A summarization block is recognizable on its own (it carries a
490
+ # "Session Intent" section), so key off the TEXT rather than the
491
+ # _post_summarization flag: that flag is only set from a main-agent
492
+ # `updates` chunk, which does not always arrive before the summary
493
+ # streams — and when it didn't, the block was announced as a plain
494
+ # discard or shown outright.
495
+ if looks_like_summarization_output(pending_text):
496
+ _post_summarization = False
497
+ # Fallback for providers that stream the summary before the graph's
498
+ # state update. The TUI holds the heading prefix, so it can replace
499
+ # the hidden internal block with the compaction animation.
500
+ out = _discard()
501
+ if not compaction_status_started:
502
+ compaction_status_started = True
503
+ out.append(
504
+ ev.ContextMessage(
505
+ message="Context is compacting",
506
+ event_type="nova_compaction_start",
507
+ icon="⟳",
508
+ color="dim",
509
+ )
510
+ )
511
+ return out
512
+
513
+ if _post_summarization:
514
+ _post_summarization = False
515
+
516
+ if is_internal_context_text(pending_text):
517
+ # Internal context echo — never shown to the user.
518
+ return _discard()
519
+
520
+ if current_ai_message_id and current_ai_message_id in seen_message_ids:
521
+ return _discard()
522
+
523
+ text = pending_text.rstrip()
524
+ msg = ev.AssistantMessage(
525
+ text=text, agent_name=agent_display_name, agent_color=agent_colors
526
+ )
527
+ if current_ai_message_id:
528
+ seen_message_ids.add(current_ai_message_id)
529
+ current_ai_message_id = None
530
+ pending_text = ""
531
+ summary_preview_suppressed = False
532
+ preview_started = False
533
+ has_responded = True
534
+ _streamed_pending = False
535
+ return [msg]
536
+
537
+ # Seed dedup set with pre-existing messages.
538
+ pre_stream_msg_count: int | None = None
539
+ # A summarization event already present before this turn is not news; only a
540
+ # NEW one means the library compacted during it (see compaction detection).
541
+ _pre_summarization_event = None
542
+ try:
543
+ _pre_state = await agent.aget_state(config)
544
+ _pre_msgs = _pre_state.values.get("messages", [])
545
+ _pre_summarization_event = _pre_state.values.get("_summarization_event")
546
+ pre_stream_msg_count = len(_pre_msgs)
547
+ for _m in _pre_msgs:
548
+ _mid = getattr(_m, "id", None)
549
+ if _mid:
550
+ seen_message_ids.add(_mid)
551
+ except Exception:
552
+ pass
553
+
554
+ stream_input: Any = {"messages": [{"role": "user", "content": message_content}]}
555
+ # Pair the active rubric (set via `/goal rubric <criteria>`) with the run so
556
+ # RubricMiddleware's grader holds the output to those criteria. Absent ⇒ the
557
+ # middleware is a no-op, so this adds nothing when no rubric is set.
558
+ _active_rubric = (
559
+ getattr(session_state, "active_rubric", None)
560
+ if (not active_goal or goal_in_scope)
561
+ and not getattr(session_state, "plan_mode_enabled", False)
562
+ else None
563
+ )
564
+ # The checkpointer retains omitted fields; explicitly clear a rubric when
565
+ # the goal pauses, completes, or is cleared so old criteria cannot leak.
566
+ stream_input["rubric"] = _active_rubric or ""
567
+ _current_stream_gen: Any = None
568
+
569
+ from novacode_cli.tools.plan_mode_tools import _auto_approve_var
570
+
571
+ auto_approve_val = bool(getattr(session_state, "auto_approve", False))
572
+ token = _auto_approve_var.set(auto_approve_val)
573
+
574
+ from novacode_cli.prime import PrimeRun, current_run, receipt
575
+
576
+ prime_run = (
577
+ PrimeRun(session_state, prime_original_input)
578
+ if legacy_prime and getattr(session_state, "prime_enabled", False) is True
579
+ else None
580
+ )
581
+ prime_token = current_run.set(prime_run)
582
+ from novacode_cli.handoff import HandoffRun, current_handoff
583
+
584
+ handoff_run = None
585
+ handoff_token = current_handoff.set(None)
586
+ from novacode_cli.config.plan_mode import active_plan_session
587
+
588
+ plan_session_token = active_plan_session.set(session_state)
589
+ from novacode_cli.security.approval_context import current_user_request
590
+
591
+ approval_request_token = current_user_request.set(prime_original_input)
592
+ from novacode_cli.core.code_approvals import CodeApprovals, current_code_approvals
593
+
594
+ code_approvals = CodeApprovals(lambda: session_state.thread_id == thread_id)
595
+ code_approvals_token = current_code_approvals.set(code_approvals)
596
+ prime_outcome = "interrupted"
597
+
598
+ # Resuming after an interrupt restarts LangGraph's recursion counter, so a
599
+ # rejection the *policy* resolves on its own (plan-mode block, deny rule) can
600
+ # spin forever: reject -> model retries the same call -> reject -> ... with no
601
+ # user in the way and no step limit to hit. LoopGuardMiddleware can't see it
602
+ # (the tool never executes), so cap the streak here.
603
+ auto_reject_streak = 0
604
+
605
+ try:
606
+ if getattr(session_state, "handoff_enabled", False) is True:
607
+ handoff_run = await asyncio.to_thread(
608
+ HandoffRun, session_state, prime_original_input, assistant_id or "nova-agent",
609
+ )
610
+ current_handoff.set(handoff_run)
611
+ if handoff_run is not None and handoff_run.error:
612
+ yield ev.ContextMessage(handoff_run.error, event_type="nova_handoff_status")
613
+ if handoff_run is not None and handoff_run.skill_warning:
614
+ yield ev.ContextMessage(handoff_run.skill_warning, event_type="nova_handoff_status")
615
+ if prime_run is not None:
616
+ yield ev.ContextMessage(
617
+ "Prime · Define · 0/6 checkpoints",
618
+ event_type="nova_prime_progress",
619
+ icon="◆",
620
+ color="cyan",
621
+ record_id=prime_run.report["id"],
622
+ data=prime_run.progress(),
623
+ )
624
+ while True:
625
+ interrupt_occurred = False
626
+ user_prompted = False
627
+ hitl_response: dict[str, Any] = {}
628
+ command_state_update: dict = {}
629
+ # Collected interrupts to resolve after the stream pauses:
630
+ # list of (interrupt_id, kind, payload)
631
+ pending_interrupts: list[tuple[str, str, Any]] = []
632
+ subagent_tracker.clear()
633
+ current_ai_message_id = None
634
+ # task_id -> last status emitted for an async (remote) dispatch. The
635
+ # launch/check/update tools each write the task back into graph state,
636
+ # so the same entry arrives repeatedly; only a status change is news.
637
+ emitted_async: dict[str, str] = {}
638
+ # Subagent message ids whose text already streamed as chunks, so the
639
+ # whole message that follows is not previewed a second time.
640
+ preview_streamed: set = set()
641
+
642
+ _current_stream_gen = scoped_stream(
643
+ traced_stream(
644
+ agent.astream(
645
+ stream_input,
646
+ # "custom" carries the quickjs eval bridge's per-dispatch
647
+ # subagent events, which is how a fan-out is visible while it
648
+ # runs. Anything else on that stream is ignored downstream.
649
+ stream_mode=["updates", "messages", "custom"],
650
+ subgraphs=True,
651
+ config=config,
652
+ durability="exit",
653
+ )
654
+ ),
655
+ "main",
656
+ )
657
+
658
+ async for chunk in _safe_stream(_current_stream_gen):
659
+ # Surface any Nova learning events (review cycles) as they happen
660
+ # so the UI's live indicator updates mid-turn rather than only at
661
+ # turn boundaries.
662
+ for _ctx in _drain_nova_events():
663
+ yield _ctx
664
+
665
+ if not isinstance(chunk, tuple) or len(chunk) != 3:
666
+ continue
667
+ _namespace, current_stream_mode, data = chunk
668
+
669
+ if current_stream_mode == "custom":
670
+ if isinstance(data, dict) and data.get("type") == "nova_skill_read":
671
+ name = " ".join(str(data.get("name") or "").split())[:200]
672
+ if name:
673
+ yield ev.ContextMessage(
674
+ message=f"Reading skill: {name}",
675
+ event_type="nova_skill_read",
676
+ icon="📖",
677
+ color="cyan",
678
+ )
679
+ continue
680
+ if isinstance(data, dict) and data.get("type") == "nova_code_approval":
681
+ pending = code_approvals.pending.get(data.get("id"))
682
+ if pending is not None and code_approvals.active:
683
+ payload, future = pending
684
+ if not future.done():
685
+ if session_state.thread_id != thread_id:
686
+ code_approvals.close()
687
+ continue
688
+ approval_payload = {
689
+ **payload, "requires_owner_approval": True,
690
+ "approval_session": runtime_session,
691
+ }
692
+ notice = session_state.add_notification(
693
+ level="approval", title="Tool action requires approval",
694
+ message=format_interrupt_notification("tool", approval_payload),
695
+ source="system", action_id=data["id"], action_type="approve",
696
+ )
697
+ session_state.register_pending_approval(data["id"], future)
698
+ try:
699
+ yield ev.InterruptRequest(
700
+ kind="tool", payload=approval_payload, future=future
701
+ )
702
+ await future
703
+ if session_state.thread_id != thread_id:
704
+ code_approvals.close()
705
+ finally:
706
+ session_state.dismiss_notification(notice)
707
+ continue
708
+ if (
709
+ prime_run is not None
710
+ and prime_run.is_current()
711
+ and isinstance(data, dict)
712
+ and data.get("type") == "nova_prime_checkpoint"
713
+ and data.get("run_id") == prime_run.report["id"]
714
+ ):
715
+ yield ev.ContextMessage(
716
+ message=f"Prime · {prime_run.report['phase'].title()} · "
717
+ f"{len(prime_run.report['checkpoints'])}/6 checkpoints",
718
+ event_type="nova_prime_progress",
719
+ icon="◆",
720
+ color="cyan",
721
+ record_id=prime_run.report["id"],
722
+ data=prime_run.progress(),
723
+ )
724
+ continue
725
+ if isinstance(data, dict) and data.get("type") == "nova_model_retry":
726
+ phase = data.get("phase")
727
+ if phase == "retry":
728
+ yield ev.StatusUpdate(
729
+ f"Retrying model request · {data.get('attempt')}/{data.get('maximum')}…"
730
+ )
731
+ elif phase == "recovered":
732
+ yield ev.StatusUpdate("thinking…")
733
+ continue
734
+ # Sent by ContextCompactionMiddleware before it asks for a
735
+ # summary: that call does not stream, so without this the UI
736
+ # would sit silent for as long as the summary takes.
737
+ if isinstance(data, dict) and data.get("type") == "nova_compaction_start":
738
+ subagent_task_id = data.get("subagent_task_id")
739
+ if subagent_task_id:
740
+ yield ev.SubagentCompaction(str(subagent_task_id), True)
741
+ continue
742
+ compaction_finished = False
743
+ usage_after_compaction = False
744
+ if not compaction_status_started:
745
+ compaction_status_started = True
746
+ yield ev.ContextMessage(
747
+ message="Context is compacting",
748
+ event_type="nova_compaction_start",
749
+ icon="⟳",
750
+ color="dim",
751
+ )
752
+ continue
753
+ # ...and after it, whether it worked or not, so the status
754
+ # ends with the summary rather than with the turn.
755
+ if isinstance(data, dict) and data.get("type") in (
756
+ "nova_compaction_end",
757
+ "nova_compaction_failed",
758
+ ):
759
+ subagent_task_id = data.get("subagent_task_id")
760
+ if subagent_task_id:
761
+ yield ev.SubagentCompaction(str(subagent_task_id), False)
762
+ continue
763
+ compaction_finished = True
764
+ usage_after_compaction = False
765
+ if compaction_status_started:
766
+ compaction_status_started = False
767
+ ok = data["type"] == "nova_compaction_end"
768
+ yield ev.ContextMessage(
769
+ message="Context compacted"
770
+ if ok
771
+ else "Context summary failed; continuing with the full history",
772
+ event_type=data["type"],
773
+ icon="⟳",
774
+ color="dim" if ok else "yellow",
775
+ )
776
+ continue
777
+ # A fan-out's tasks, one event each as they start and finish.
778
+ # The mapper rejects every other custom payload, so middleware
779
+ # writing to this stream costs nothing here.
780
+ _task = task_from_custom_event(data, now=time.time())
781
+ if _task is not None:
782
+ yield _task
783
+ continue
784
+
785
+ if current_stream_mode == "updates":
786
+ if not isinstance(data, dict):
787
+ continue
788
+
789
+ chunk_data = None
790
+ for _node_state in data.values():
791
+ if isinstance(_node_state, dict):
792
+ if chunk_data is None:
793
+ chunk_data = _node_state
794
+ if "todos" in _node_state:
795
+ _new_todos = _node_state["todos"]
796
+ yield ev.TodoUpdate(
797
+ todos=_new_todos,
798
+ agent_name=_node_state.get("agent_name"),
799
+ )
800
+ break
801
+
802
+ # Async (remote) subagents, at dispatch rather than at turn end.
803
+ # `start_async_task` returns a Command whose state update carries
804
+ # the new task, so the row is available here the moment the
805
+ # launch tool returns. The Done-time watcher is the only other
806
+ # source, which is why these rows used to appear a whole turn
807
+ # late. It still owns completion notifications (6s polling).
808
+ for _node_state in data.values():
809
+ if not isinstance(_node_state, dict):
810
+ continue
811
+ _async_tasks = _node_state.get("async_tasks")
812
+ if not isinstance(_async_tasks, dict):
813
+ continue
814
+ for _entry in _async_tasks.values():
815
+ _row = task_from_async_entry(_entry, now=time.time())
816
+ if _row is None:
817
+ continue
818
+ if emitted_async.get(_row.task_id) == _row.status:
819
+ continue
820
+ emitted_async[_row.task_id] = _row.status
821
+ yield _row
822
+
823
+ if "__interrupt__" in data:
824
+ interrupts: list[Interrupt] = data["__interrupt__"]
825
+ for interrupt_obj in interrupts:
826
+ interrupt_value = interrupt_obj.value
827
+ # ask_question interrupt
828
+ if isinstance(interrupt_value, dict) and (
829
+ interrupt_value.get("type") == "question"
830
+ or (
831
+ interrupt_value.get("type") == "tool"
832
+ and interrupt_value.get("name")
833
+ in ("ask_question", "ask_user_question")
834
+ )
835
+ ):
836
+ pending_interrupts.append(
837
+ (
838
+ interrupt_obj.id,
839
+ "question",
840
+ interrupt_value.get("request", {}),
841
+ )
842
+ )
843
+ interrupt_occurred = True
844
+ continue
845
+ # plan approval interrupt
846
+ if (
847
+ isinstance(interrupt_value, dict)
848
+ and interrupt_value.get("type") == "plan_approval"
849
+ ):
850
+ pending_interrupts.append(
851
+ (interrupt_obj.id, "plan", interrupt_value)
852
+ )
853
+ interrupt_occurred = True
854
+ continue
855
+ # generic tool HITL interrupt
856
+ try:
857
+ validated = _HITL_REQUEST_ADAPTER.validate_python(interrupt_value)
858
+ pending_interrupts.append((
859
+ interrupt_obj.id, "tool",
860
+ {**validated, "approval_session": runtime_session},
861
+ ))
862
+ interrupt_occurred = True
863
+ except ValidationError as e:
864
+ yield ev.Error(f"Invalid HITL request data: {e}")
865
+ raise
866
+
867
+ # summarization detection
868
+ if chunk_data and isinstance(chunk_data, dict):
869
+ _msgs = chunk_data.get("messages")
870
+ if _msgs is not None:
871
+ if hasattr(_msgs, "value"):
872
+ _msgs = _msgs.value
873
+ if not isinstance(_msgs, list):
874
+ try:
875
+ _msgs = list(_msgs)
876
+ except TypeError:
877
+ _msgs = []
878
+ for _msg in _msgs:
879
+ # A summary already in the thread (a /resume seed)
880
+ # comes back whenever a node rewrites the history.
881
+ if (
882
+ getattr(_msg, "additional_kwargs", {}).get("lc_source")
883
+ == "summarization"
884
+ and getattr(_msg, "id", None) not in seen_message_ids
885
+ ):
886
+ _post_summarization = True
887
+ if not compaction_status_started:
888
+ compaction_status_started = True
889
+ yield ev.ContextMessage(
890
+ message="Context is compacting",
891
+ event_type="nova_compaction_start",
892
+ icon="⟳",
893
+ color="dim",
894
+ )
895
+ break
896
+
897
+ elif current_stream_mode == "messages":
898
+ is_main_agent = _namespace == ()
899
+ is_subagent = _namespace != ()
900
+ subagent_type_for_ns = _namespace[0] if is_subagent and _namespace else None
901
+
902
+ if not isinstance(data, tuple) or len(data) != 2:
903
+ continue
904
+ message, _metadata = data
905
+ # Which panel row this subagent message belongs to (stamped by
906
+ # the task tool, see core_agent._build_task_tool_with_task_id).
907
+ preview_id = (
908
+ _metadata.get(TASK_ID_METADATA_KEY)
909
+ if is_subagent and isinstance(_metadata, dict)
910
+ else None
911
+ )
912
+
913
+ # Hermes runs its review/skill-refine model calls out-of-band
914
+ # as fire-and-forget asyncio tasks spawned from inside the
915
+ # agent's model call. Those tasks inherit LangGraph's message
916
+ # streaming callback via contextvars, so their full output
917
+ # (e.g. a regenerated SKILL.md) would otherwise surface here as
918
+ # a "Nova" assistant message. They're tagged nova_oob — drop
919
+ # them; their user-facing notices come via nova_event_log.
920
+ # Summary requests must not replace main-model context usage.
921
+ if isinstance(_metadata, dict) and (
922
+ _metadata.get("nova_oob")
923
+ or _metadata.get("lc_source") == "summarization"
924
+ or "hermes" in (_metadata.get("tags") or [])
925
+ ):
926
+ continue
927
+
928
+ _ckpt_ns = (
929
+ _metadata.get("langgraph_checkpoint_ns", "")
930
+ if isinstance(_metadata, dict)
931
+ else ""
932
+ )
933
+ if _ckpt_ns and "before_model" in _ckpt_ns:
934
+ continue
935
+
936
+ if getattr(message, "id", None) in seen_before_turn:
937
+ continue
938
+ message_already_seen = getattr(message, "id", None) in seen_message_ids
939
+ if isinstance(message, HumanMessage):
940
+ continue
941
+
942
+ if isinstance(message, ToolMessage):
943
+ async for _e in _handle_tool_message(
944
+ message,
945
+ namespace=_namespace,
946
+ is_main_agent=is_main_agent,
947
+ is_subagent=is_subagent,
948
+ tool_call_to_name=tool_call_to_name,
949
+ displayed_tool_ids=displayed_tool_ids,
950
+ seen_message_ids=seen_message_ids,
951
+ file_op_tracker=file_op_tracker,
952
+ subagent_tracker=subagent_tracker,
953
+ agent_display_name=agent_display_name,
954
+ flush_text=_flush_events,
955
+ preview_task_id=preview_id,
956
+ ):
957
+ yield _e
958
+ continue
959
+
960
+ # Build content blocks
961
+ if hasattr(message, "content_blocks"):
962
+ blocks: list[dict] = message.content_blocks
963
+ else:
964
+ blocks = []
965
+ raw = getattr(message, "content", "")
966
+ if isinstance(raw, str) and raw:
967
+ blocks.append({"type": "text", "text": raw})
968
+ elif isinstance(raw, list):
969
+ for item in raw:
970
+ if isinstance(item, dict):
971
+ blocks.append(item)
972
+ elif isinstance(item, str) and item:
973
+ blocks.append({"type": "text", "text": item})
974
+ for tc in getattr(message, "tool_call_chunks", []) or []:
975
+ if isinstance(tc, dict):
976
+ blocks.append({"type": "tool_call_chunk", **tc})
977
+
978
+ # Usage capture (main agent). MUST run before the empty-blocks
979
+ # early-continue below: providers (Ollama, OpenAI, Anthropic)
980
+ # attach usage_metadata to a *final chunk with empty content*,
981
+ # which has no blocks — skipping it here lost all token counts.
982
+ if is_main_agent:
983
+ from types import SimpleNamespace
984
+
985
+ from novacode_cli.tracking.usage_tree import _extract_usage
986
+
987
+ usage = _extract_usage(SimpleNamespace(
988
+ generations=[[SimpleNamespace(message=message)]]
989
+ ))
990
+ if usage:
991
+ cache_read = usage.get("cache_read_tokens", 0)
992
+ cache_create = usage.get("cache_creation_tokens", 0)
993
+ actual = usage.get("input_tokens", 0)
994
+ out = usage.get("output_tokens", 0)
995
+ if actual or out:
996
+ metadata = _metadata if isinstance(_metadata, dict) else {}
997
+ call_id = getattr(message, "id", None) or (
998
+ metadata.get("langgraph_step"),
999
+ metadata.get("langgraph_node"),
1000
+ metadata.get("langgraph_checkpoint_ns"),
1001
+ )
1002
+ old_input, old_output, old_cache_read, old_cache_create = (
1003
+ usage_calls.get(call_id, (0, 0, 0, 0))
1004
+ )
1005
+ usage_calls[call_id] = (
1006
+ max(old_input, actual),
1007
+ max(old_output, out),
1008
+ max(old_cache_read, int(cache_read or 0)),
1009
+ max(old_cache_create, int(cache_create or 0)),
1010
+ )
1011
+ call_usage = usage_calls[call_id]
1012
+ captured.input_tokens, captured.output_tokens = call_usage[:2]
1013
+ if compaction_finished and captured.input_tokens > 0:
1014
+ usage_after_compaction = True
1015
+ captured.cache_read_tokens = sum(u[2] for u in usage_calls.values())
1016
+ captured.cache_creation_tokens = sum(
1017
+ u[3] for u in usage_calls.values()
1018
+ )
1019
+ captured.session_tokens = sum(
1020
+ i + o for i, o, _, _ in usage_calls.values()
1021
+ )
1022
+ captured.session_input_tokens = sum(
1023
+ i for i, _, _, _ in usage_calls.values()
1024
+ )
1025
+ if captured.input_tokens != context_shown:
1026
+ context_shown = captured.input_tokens
1027
+ yield ev.ContextUsage(context_shown)
1028
+
1029
+ _is_completed_msg = type(message).__name__ == "AIMessage"
1030
+ reasoning_key = (str(_namespace), getattr(message, "id", None))
1031
+ thought = reasoning_text(message, blocks) if is_main_agent else ""
1032
+ if thought and (
1033
+ not _is_completed_msg
1034
+ or reasoning_key[1] is None
1035
+ or reasoning_key not in streamed_reasoning_ids
1036
+ ):
1037
+ yield ev.ReasoningDelta(thought)
1038
+ if reasoning_key[1] is not None:
1039
+ streamed_reasoning_ids.add(reasoning_key)
1040
+ if text_guard.feed(thought):
1041
+ raise _RepetitionLoop
1042
+
1043
+ if message_already_seen or not blocks:
1044
+ continue
1045
+
1046
+ for block in blocks:
1047
+ btype = block.get("type")
1048
+ if btype == "text" and block.get("thought") is not True:
1049
+ text = block.get("text", "")
1050
+ if text and is_main_agent and not _is_completed_msg:
1051
+ current_ai_message_id = getattr(message, "id", None)
1052
+ pending_text += text
1053
+ if looks_like_summarization_output(pending_text):
1054
+ summary_preview_suppressed = True
1055
+ if not compaction_status_started:
1056
+ compaction_status_started = True
1057
+ yield ev.ContextMessage(
1058
+ message="Context is compacting",
1059
+ event_type="nova_compaction_start",
1060
+ icon="⟳",
1061
+ color="dim",
1062
+ )
1063
+ if not summary_preview_suppressed and (
1064
+ preview_started
1065
+ or not _could_be_session_intent_heading(pending_text)
1066
+ ):
1067
+ preview_text = text if preview_started else pending_text
1068
+ preview_started = True
1069
+ _streamed_pending = True
1070
+ yield ev.TextDelta(preview_text)
1071
+ if text_guard.feed(text):
1072
+ raise _RepetitionLoop
1073
+ elif text and preview_id:
1074
+ # Chunks as they stream; a model that does not
1075
+ # stream delivers one whole message instead.
1076
+ _mid = getattr(message, "id", None)
1077
+ if not _is_completed_msg:
1078
+ preview_streamed.add(_mid)
1079
+ yield ev.SubagentPreview(preview_id, "text", text)
1080
+ elif _mid not in preview_streamed:
1081
+ yield ev.SubagentPreview(preview_id, "text", text)
1082
+ elif btype in ("tool_call_chunk", "tool_call"):
1083
+ async for _e in _handle_tool_call_chunk(
1084
+ block,
1085
+ is_main_agent=is_main_agent,
1086
+ namespace=_namespace,
1087
+ subagent_type_for_ns=subagent_type_for_ns,
1088
+ tool_call_buffers=tool_call_buffers,
1089
+ displayed_tool_ids=displayed_tool_ids,
1090
+ tool_call_to_name=tool_call_to_name,
1091
+ file_op_tracker=file_op_tracker,
1092
+ subagent_tracker=subagent_tracker,
1093
+ flush_text=_flush_events,
1094
+ preview_task_id=preview_id,
1095
+ ):
1096
+ # The agent can self-engage plan mode: when it calls
1097
+ # enter_plan_mode, flip the session flag so the
1098
+ # pre-HITL gate (check_plan_mode_blocked) starts
1099
+ # blocking writes/shell for the rest of the turn —
1100
+ # making the agent read-only until exit_plan_mode is
1101
+ # approved. exit_plan_mode's approval clears it.
1102
+ if (
1103
+ isinstance(_e, ev.ToolCall)
1104
+ and _e.name == "enter_plan_mode"
1105
+ and is_main_agent
1106
+ ):
1107
+ try:
1108
+ session_state.plan_mode_enabled = True
1109
+ except Exception: # noqa: BLE001
1110
+ pass
1111
+ # A tool call is proof the turn is advancing, so
1112
+ # prose repeats from before it must not count
1113
+ # toward the "going nowhere" verdict. The guard
1114
+ # exists for prose with NO tool calls at all;
1115
+ # without this reset it killed long productive
1116
+ # turns whose narration repeats around each step.
1117
+ if isinstance(_e, ev.ToolCall):
1118
+ text_guard.note_progress()
1119
+ yield _e
1120
+
1121
+ if getattr(message, "chunk_position", None) == "last":
1122
+ if hasattr(message, "id"):
1123
+ seen_message_ids.add(message.id)
1124
+ for _e in _flush_events():
1125
+ yield _e
1126
+
1127
+ _current_stream_gen = None
1128
+
1129
+ # Flush any trailing prose.
1130
+ for _e in _flush_events():
1131
+ yield _e
1132
+
1133
+ # Resolve interrupts (graph is paused).
1134
+ if interrupt_occurred:
1135
+ any_rejected = False
1136
+ plan_approved = False
1137
+ plan_handoff_required = False
1138
+ # (plan text, approved by Nova itself?) for the approved plan.
1139
+ approved_plan: tuple[str, bool] | None = None
1140
+ # When the turn auto-approves (e.g. a /remote turn sets
1141
+ # auto_approve=True), no permission is actually being asked of the
1142
+ # user — the interrupt is resolved automatically — so a badge /
1143
+ # desktop notification would just be noise. Skip it there; local,
1144
+ # interactive turns (auto_approve off) still notify.
1145
+ _auto_approve = bool(getattr(session_state, "auto_approve", False))
1146
+ from novacode_cli.ui.hitl_approval import evaluate_tool_actions
1147
+
1148
+ for interrupt_id, kind, payload in pending_interrupts:
1149
+ # Pre-HITL policy gate: auto-resolve the actions that don't need
1150
+ # a prompt (policy-allow → approve, policy-deny → reject, plus
1151
+ # plan-mode/auto_approve) so the safe majority never interrupts
1152
+ # and hard-denies block without asking. Only a genuine "ask"
1153
+ # falls through to surface an InterruptRequest + notification.
1154
+ _policy_resolutions: list[dict | None] | None = None
1155
+ if kind == "plan" and _auto_approve:
1156
+ # Auto-approve plan without prompting
1157
+ # auto_approved, not mode: the TUI's "approve + auto-accept
1158
+ # edits" choice is ALSO mode "auto", and that one is the
1159
+ # user approving. This flag means nobody was asked.
1160
+ hitl_response[interrupt_id] = {
1161
+ "approved": True,
1162
+ "mode": "auto",
1163
+ "auto_approved": True,
1164
+ }
1165
+ command_state_update.update({"plan_mode_enabled": False})
1166
+ plan_approved = True
1167
+ approved_plan = ((payload or {}).get("plan") or "", True)
1168
+
1169
+ # Set plan_mode_enabled=False on session_state and clear plan agent
1170
+ session_state.plan_mode_enabled = False
1171
+ using_separate_plan_agent = (
1172
+ hasattr(session_state, "plan_agent")
1173
+ and session_state.plan_agent is not None
1174
+ )
1175
+ plan_handoff_required = using_separate_plan_agent or bool(
1176
+ getattr(session_state, "plan_execution_handoff", False)
1177
+ )
1178
+ # Keep the planner/backend alive until the shared handoff
1179
+ # resolves plans that were written to .nova/plans/.
1180
+ session_state.plan_content = None
1181
+
1182
+ yield ev.ContextMessage(
1183
+ "Plan Approved - switching to execution mode (auto-approved)",
1184
+ icon="✓",
1185
+ color="green",
1186
+ )
1187
+ continue
1188
+
1189
+ if kind == "tool":
1190
+ try:
1191
+ _policy_resolutions = await asyncio.to_thread(
1192
+ evaluate_tool_actions,
1193
+ payload,
1194
+ session_state,
1195
+ plan_mode_enabled=getattr(session_state, "plan_mode_enabled", False),
1196
+ user_request=message_content,
1197
+ )
1198
+ except Exception: # noqa: BLE001 — never break the turn
1199
+ _policy_resolutions = None
1200
+ if _policy_resolutions is not None and all(
1201
+ r is not None for r in _policy_resolutions
1202
+ ):
1203
+ _decided = [r for r in _policy_resolutions if r is not None]
1204
+ hitl_response[interrupt_id] = {"decisions": _decided}
1205
+ if any(d.get("type") == "reject" for d in _decided):
1206
+ any_rejected = True
1207
+ action_requests = (
1208
+ payload.get("action_requests", [])
1209
+ if isinstance(payload, dict)
1210
+ else []
1211
+ )
1212
+ for index, decision in enumerate(_decided):
1213
+ source = decision.get("approval_source")
1214
+ if source not in {"decision_model", "safety_filter", "unavailable"}:
1215
+ continue
1216
+ action = (
1217
+ action_requests[index]
1218
+ if index < len(action_requests)
1219
+ and isinstance(action_requests[index], dict)
1220
+ else {}
1221
+ )
1222
+ approved = decision.get("type") == "approve"
1223
+ action_name = action.get("name", "tool action")
1224
+ if approved:
1225
+ model_name = decision.get("decision_model", "Decision model")
1226
+ message = f"Decision model {model_name} approved {action_name}"
1227
+ icon, color = "✓", "green"
1228
+ else:
1229
+ if source == "safety_filter":
1230
+ message = f"Safety filter rejected {action_name}"
1231
+ elif source == "unavailable":
1232
+ message = f"Decision model unavailable; rejected {action_name}"
1233
+ else:
1234
+ model_name = decision.get("decision_model", "Decision model")
1235
+ message = f"Decision model {model_name} rejected {action_name}"
1236
+ icon, color = "✗", "red"
1237
+ if decision.get("reason"):
1238
+ message += f" · {decision['reason']}"
1239
+ yield ev.ContextMessage(message, icon=icon, color=color)
1240
+ continue
1241
+
1242
+ # A genuine ask remains interactive even in auto mode.
1243
+ # Resolvers must not run the reviewer a second time.
1244
+ approval_payload = (
1245
+ {**payload, "requires_owner_approval": True} if kind == "tool" else payload
1246
+ )
1247
+ fut: asyncio.Future = loop.create_future()
1248
+
1249
+ # Raise the notification BEFORE surfacing the interrupt, so the
1250
+ # badge/notification hook fire the moment approval is needed —
1251
+ # not after the modal is answered (the old order ran this only
1252
+ # once the generator resumed past the yield). Skipped entirely
1253
+ # when auto-approving (see above).
1254
+ _notif_id: str | None = None
1255
+ if kind in ("tool", "plan", "question") and (
1256
+ not _auto_approve or kind == "tool"
1257
+ ):
1258
+ _notif_msg = (
1259
+ "Plan requires approval"
1260
+ if kind == "plan"
1261
+ else (
1262
+ "Tool action requires approval"
1263
+ if kind == "tool"
1264
+ else "Question requires response"
1265
+ )
1266
+ )
1267
+ try:
1268
+ _notif_id = session_state.add_notification(
1269
+ level="approval",
1270
+ title=_notif_msg,
1271
+ message=format_interrupt_notification(kind, approval_payload),
1272
+ source="system",
1273
+ action_id=interrupt_id,
1274
+ action_type=("approve" if kind in ("tool", "plan") else "select"),
1275
+ )
1276
+ session_state.register_pending_approval(interrupt_id, fut)
1277
+ except Exception: # noqa: BLE001 — never break the turn on a notification
1278
+ pass
1279
+
1280
+ user_prompted = True
1281
+ yield ev.InterruptRequest(kind=kind, payload=approval_payload, future=fut)
1282
+ response = await fut
1283
+
1284
+ # Resolved (via the modal or /notifications) — clear the badge
1285
+ # so a settled approval doesn't linger as "pending".
1286
+ if _notif_id is not None:
1287
+ try:
1288
+ session_state.dismiss_notification(_notif_id)
1289
+ except Exception: # noqa: BLE001
1290
+ pass
1291
+ if kind == "tool":
1292
+ _user_decisions = list(response.get("decisions", []))
1293
+ # Overlay policy verdicts onto the user's batch decision:
1294
+ # policy-decided slots (allow/deny) win, only the "ask"
1295
+ # slots take the user's choice — so a hard deny in a mixed
1296
+ # batch is honored even if the user approved the prompt.
1297
+ if _policy_resolutions is not None:
1298
+ _final: list[dict] = []
1299
+ for _i, _r in enumerate(_policy_resolutions):
1300
+ if _r is not None:
1301
+ _final.append(_r)
1302
+ elif _i < len(_user_decisions):
1303
+ _final.append(_user_decisions[_i])
1304
+ else:
1305
+ _final.append({"type": "reject", "message": "No decision"})
1306
+ _user_decisions = _final
1307
+ hitl_response[interrupt_id] = {"decisions": _user_decisions}
1308
+ if response.get("any_rejected") or any(
1309
+ d.get("type") == "reject" for d in _user_decisions
1310
+ ):
1311
+ any_rejected = True
1312
+ elif kind == "question":
1313
+ hitl_response[interrupt_id] = response
1314
+ elif kind == "plan":
1315
+ resp_data = response.get("response", {})
1316
+ hitl_response[interrupt_id] = resp_data
1317
+ su = response.get("state_update")
1318
+ if su:
1319
+ command_state_update.update(su)
1320
+ # Mirror the approval onto THIS loop's session_state,
1321
+ # not just the graph update. The pre-HITL gate reads
1322
+ # session_state.plan_mode_enabled, so a resolver that
1323
+ # can only reach a copy of the state — the parent TUI
1324
+ # answering a spawned session's approval, where the
1325
+ # flag it clears is a proxy in another process — left
1326
+ # the real flag set and every write stayed auto-
1327
+ # rejected after the plan was approved (plan mode
1328
+ # appeared to hang forever).
1329
+ if "plan_mode_enabled" in su:
1330
+ try:
1331
+ session_state.plan_mode_enabled = su["plan_mode_enabled"]
1332
+ except Exception: # noqa: BLE001
1333
+ pass
1334
+ # When the plan is approved, don't resume the plan agent —
1335
+ # the caller (TUI or CLI) handles the hand-off to the main
1336
+ # execution agent. Resuming would let the model continue its
1337
+ # turn and potentially re-enter plan mode.
1338
+ if resp_data.get("approved"):
1339
+ plan_approved = True
1340
+ _inline = payload.get("plan") if isinstance(payload, dict) else ""
1341
+ approved_plan = (_inline or "", False)
1342
+ plan_handoff_required = (
1343
+ getattr(session_state, "plan_agent", None) is not None
1344
+ or bool(getattr(session_state, "plan_execution_handoff", False))
1345
+ )
1346
+
1347
+ if any_rejected:
1348
+ yield ev.ErrorOutput("Command rejected. Tell the agent what to do differently.")
1349
+
1350
+ # Only a rejection nobody was asked about can loop unattended.
1351
+ if any_rejected and not user_prompted:
1352
+ auto_reject_streak += 1
1353
+ if auto_reject_streak >= MAX_AUTO_REJECT_RESUMES:
1354
+ yield ev.Error(
1355
+ f"Stopped: the agent retried a blocked action "
1356
+ f"{auto_reject_streak} times in a row and it was rejected "
1357
+ "automatically each time (plan mode or an approval-policy "
1358
+ "deny rule). Tell it to take a different approach, or "
1359
+ "approve the action."
1360
+ )
1361
+ break
1362
+ else:
1363
+ auto_reject_streak = 0
1364
+
1365
+ # The TUI and separate plan agent always hand off through a clean
1366
+ # execution thread. Other non-interactive self-planning callers keep
1367
+ # their legacy in-context behavior.
1368
+ if plan_approved and plan_handoff_required:
1369
+ approved_text, auto_approved = approved_plan or ("", False)
1370
+ try:
1371
+ from novacode_cli.ui.interrupt_handlers import resolve_plan_content
1372
+
1373
+ resolved, plan_file = resolve_plan_content(
1374
+ getattr(session_state, "todos", None),
1375
+ session_state,
1376
+ backend=getattr(session_state, "plan_backend", None) or backend,
1377
+ inline_plan=(
1378
+ approved_text
1379
+ or getattr(session_state, "approved_plan_content", None)
1380
+ ),
1381
+ )
1382
+ except Exception: # noqa: BLE001 — approval must not crash on plan lookup
1383
+ resolved, plan_file = approved_text or None, None
1384
+ if not resolved or not resolved.strip():
1385
+ yield ev.Error(
1386
+ "The plan was approved, but Nova could not load its full text; "
1387
+ "execution was not started."
1388
+ )
1389
+ break
1390
+
1391
+ if approved_text or auto_approved or plan_file is None:
1392
+ saved = await asyncio.to_thread(
1393
+ _save_unresumed_plan, (resolved, auto_approved), session_state
1394
+ )
1395
+ if saved:
1396
+ yield ev.ContextMessage(f"Plan saved to {saved}", icon="✓", color="green")
1397
+ try:
1398
+ session_state.set_approved_plan(resolved)
1399
+ except Exception: # noqa: BLE001
1400
+ pass
1401
+ try:
1402
+ session_state.clear_plan_agent()
1403
+ except Exception: # noqa: BLE001
1404
+ pass
1405
+ if plan_file is not None and not approved_text and not auto_approved:
1406
+ yield ev.ContextMessage(
1407
+ f"Using the approved plan from {plan_file}", icon="✓", color="green"
1408
+ )
1409
+ break
1410
+
1411
+ stream_input = Command(
1412
+ resume=hitl_response,
1413
+ update=command_state_update or None,
1414
+ )
1415
+ continue
1416
+
1417
+ break
1418
+
1419
+ # Compaction detection.
1420
+ #
1421
+ # Two shapes, and only one shrinks the message list:
1422
+ # - Nova's own /compact REPLACES history with a summary (count drops).
1423
+ # - ContextCompactionMiddleware does NOT delete messages; it
1424
+ # records a `_summarization_event` carrying a cutoff_index and
1425
+ # rebuilds the effective list from it. The count is unchanged.
1426
+ # Detecting only the count drop meant a library compaction never emitted
1427
+ # CompactionNotice, so the tracker kept the turn's PRE-compaction
1428
+ # API total (its peak) and reported e.g. 85% when the real context was
1429
+ # ~10% — and Nova then auto-compacted an already-compacted conversation.
1430
+ try:
1431
+ if pre_stream_msg_count is not None:
1432
+ _post_state = await agent.aget_state(config)
1433
+ if _post_state is not None:
1434
+ _vals = _post_state.values
1435
+ post_count = len(_vals.get("messages", []))
1436
+ _summarized = _vals.get("_summarization_event")
1437
+ if (
1438
+ _summarized and _summarized != _pre_summarization_event
1439
+ ) or post_count < pre_stream_msg_count - 2:
1440
+ compacted = True
1441
+ except Exception:
1442
+ pass
1443
+
1444
+ prime_outcome = "complete"
1445
+ except asyncio.CancelledError:
1446
+ prime_outcome = "cancelled"
1447
+ _gen = _current_stream_gen
1448
+ _current_stream_gen = None
1449
+ if _gen is not None:
1450
+ try:
1451
+ await asyncio.wait_for(asyncio.shield(_gen.aclose()), timeout=2.0)
1452
+ except Exception:
1453
+ pass
1454
+ try:
1455
+ await asyncio.wait_for(
1456
+ asyncio.shield(
1457
+ _record_cancellation(agent, config)
1458
+ ),
1459
+ timeout=3.0,
1460
+ )
1461
+ except Exception:
1462
+ pass
1463
+ yield ev.Cancelled()
1464
+ return
1465
+
1466
+ except Exception as e: # noqa: BLE001
1467
+ prime_outcome = "error"
1468
+ # Promptly release the underlying LangGraph stream rather than leaving it
1469
+ # for GC-time finalization.
1470
+ _gen = _current_stream_gen
1471
+ _current_stream_gen = None
1472
+ if _gen is not None:
1473
+ try:
1474
+ await asyncio.wait_for(asyncio.shield(_gen.aclose()), timeout=2.0)
1475
+ except Exception: # noqa: BLE001
1476
+ pass
1477
+ # A GraphInterrupt that reaches here came from a NESTED graph (a `task`
1478
+ # subagent hitting a HITL-gated tool like shell/execute/write_file).
1479
+ # The parent stream only auto-resolves interrupts surfaced as the
1480
+ # top-level `__interrupt__` event, not exceptions bubbling out of a
1481
+ # subagent — so render a concise, actionable note instead of dumping the
1482
+ # raw interrupt value as a scary error.
1483
+ if isinstance(e, _RepetitionLoop):
1484
+ # Drop the runaway preview rather than committing the repeated blob
1485
+ # as a message — the user watched it stream, and rendering it as
1486
+ # markdown is exactly the work that was killing the UI.
1487
+ yield ev.TextDiscard()
1488
+ yield ev.Error(
1489
+ "Stopped: the model started repeating itself verbatim and the "
1490
+ "response was going nowhere. Nothing was committed for this "
1491
+ "turn — rephrase the request or narrow it down."
1492
+ )
1493
+ return
1494
+ try:
1495
+ from langgraph.errors import GraphInterrupt
1496
+ except Exception: # noqa: BLE001
1497
+ GraphInterrupt = () # type: ignore[assignment]
1498
+ if GraphInterrupt and isinstance(e, GraphInterrupt):
1499
+ yield ev.Error(
1500
+ "A subagent requested approval for a guarded tool "
1501
+ "(shell/execute/write) and the run could not auto-approve it. "
1502
+ "Re-run with auto-approve enabled, or avoid shell commands in "
1503
+ "automated flows.",
1504
+ exception=e,
1505
+ )
1506
+ return
1507
+ # Recognised provider failures (usage/rate limit, auth, connectivity)
1508
+ # get a clean, actionable notice instead of a raw SDK string/traceback —
1509
+ # surfaced identically in both front-ends.
1510
+ from novacode_cli.errors import friendly_model_error, is_context_overflow
1511
+
1512
+ # Context-window overflow is recoverable: emit a dedicated event so the
1513
+ # TUI can compact and retry once instead of just showing the error.
1514
+ if is_context_overflow(e):
1515
+ friendly = friendly_model_error(e) or str(e)
1516
+ yield ev.ContextOverflow(friendly, exception=e)
1517
+ return
1518
+ friendly = friendly_model_error(e)
1519
+ if friendly is not None:
1520
+ yield ev.Error(friendly, exception=e, is_provider_notice=True)
1521
+ return
1522
+ yield ev.Error(str(e), exception=e)
1523
+ return
1524
+ finally:
1525
+ code_approvals.close()
1526
+ current_code_approvals.reset(code_approvals_token)
1527
+ _auto_approve_var.reset(token)
1528
+ active_plan_session.reset(plan_session_token)
1529
+ current_user_request.reset(approval_request_token)
1530
+ if prime_run is not None:
1531
+ prime_run.finish(prime_outcome)
1532
+ current_run.reset(prime_token)
1533
+ try:
1534
+ if handoff_run is not None:
1535
+ await asyncio.to_thread(handoff_run.finish, prime_outcome)
1536
+ finally:
1537
+ current_handoff.reset(handoff_token)
1538
+
1539
+ if captured.input_tokens or captured.output_tokens:
1540
+ yield captured
1541
+ if compacted:
1542
+ yield ev.CompactionNotice(preserve_api_context=usage_after_compaction)
1543
+ for _ctx in _drain_nova_events():
1544
+ yield _ctx
1545
+ if prime_run is not None and prime_run.is_current():
1546
+ yield ev.ContextMessage(
1547
+ await asyncio.to_thread(receipt, prime_run.report),
1548
+ event_type="nova_prime_receipt",
1549
+ icon="◆",
1550
+ color="cyan",
1551
+ record_id=prime_run.report["id"],
1552
+ data=prime_run.progress(),
1553
+ )
1554
+ if handoff_run is not None:
1555
+ yield ev.ContextMessage(
1556
+ handoff_run.notice(),
1557
+ event_type="nova_handoff_status",
1558
+ record_id=handoff_run.record["id"],
1559
+ data={"summary_status": handoff_run.record["summary_status"]},
1560
+ )
1561
+ yield ev.Done(had_response=has_responded)
1562
+
1563
+
1564
+ async def _handle_tool_message(
1565
+ message,
1566
+ *,
1567
+ namespace,
1568
+ is_main_agent: bool,
1569
+ is_subagent: bool,
1570
+ tool_call_to_name: dict,
1571
+ displayed_tool_ids: set,
1572
+ seen_message_ids: set,
1573
+ file_op_tracker,
1574
+ subagent_tracker: SubagentTracker,
1575
+ agent_display_name: str,
1576
+ flush_text,
1577
+ preview_task_id: str | None = None,
1578
+ ) -> AsyncIterator[Any]:
1579
+ """Translate a ToolMessage into events (results, file ops, subagent status)."""
1580
+ tool_id = getattr(message, "id", None) or getattr(message, "tool_call_id", None)
1581
+ if tool_id and tool_id in seen_message_ids:
1582
+ return
1583
+ if tool_id:
1584
+ seen_message_ids.add(tool_id)
1585
+
1586
+ tool_call_id = getattr(message, "tool_call_id", None)
1587
+ tool_name = tool_call_to_name.get(tool_call_id, "") or getattr(message, "name", "")
1588
+ tool_status = getattr(message, "status", "success")
1589
+ tool_content = format_tool_message_content(message.content)
1590
+ record = file_op_tracker.complete_with_message(message)
1591
+
1592
+ if is_subagent and namespace:
1593
+ if tool_status != "success" or (
1594
+ tool_content and str(tool_content).lower().startswith("error")
1595
+ ):
1596
+ subagent_tracker.record_error(namespace, tool_name)
1597
+ # No live SubagentActivity("tool_result") event is emitted here — per-tool
1598
+ # cards clutter the UI. Errors are still recorded above for the completion
1599
+ # summary, and the dispatched/completed events carry the overall status.
1600
+ # The panel's preview does want each result, but only shows it on demand.
1601
+ if preview_task_id:
1602
+ # Sent even with no summary (file tools have none): it is what marks
1603
+ # the call as finished in the preview.
1604
+ _line = format_tool_result_preview(tool_name, tool_content, tool_status) or ""
1605
+ _failed = tool_status != "success" or _line.startswith("✗")
1606
+ yield ev.SubagentPreview(
1607
+ preview_task_id, "error" if _failed else "result", _line, tool_call_id or ""
1608
+ )
1609
+
1610
+ # Completed subagent (task tool)
1611
+ if tool_name == "task" and tool_call_id and tool_call_id in subagent_tracker.active_subagents:
1612
+ info = subagent_tracker.complete_subagent(tool_call_id)
1613
+ if info:
1614
+ subagent_type, description, start_time = info
1615
+ else:
1616
+ subagent_type, description, start_time = "unknown", "", time.time()
1617
+ activity = subagent_tracker.claim_namespace_for_tool_call(namespace, tool_call_id)
1618
+ yield ev.SubagentActivity(
1619
+ kind="completed",
1620
+ subagent_type=subagent_type,
1621
+ message=f"{get_status_icon(tool_status == 'success')} {subagent_type}",
1622
+ detail=(format_condensed_activity(activity) if activity else None),
1623
+ color=get_agent_color(subagent_type),
1624
+ call_id=tool_call_id,
1625
+ )
1626
+ # The panel's row for the same dispatch, now with its duration (the
1627
+ # tracker recorded the start and it was previously thrown away) and the
1628
+ # failure text, which the card only ever showed as a status icon.
1629
+ yield task_from_sync_completion(
1630
+ tool_call_id,
1631
+ now=time.time(),
1632
+ ok=tool_status == "success",
1633
+ error="" if tool_status == "success" else str(tool_content or "")[:400],
1634
+ started_at=start_time,
1635
+ subagent_type=subagent_type,
1636
+ description=description,
1637
+ )
1638
+ yield ev.StatusUpdate(
1639
+ status_phrases.status_line("synthesizing", agent_display_name)
1640
+ )
1641
+ return
1642
+
1643
+ if is_main_agent:
1644
+ # Surface shell / generic errors
1645
+ if tool_name == "shell" and tool_status != "success" and tool_content:
1646
+ for _e in flush_text():
1647
+ yield _e
1648
+ yield ev.ErrorOutput(tool_content)
1649
+ elif tool_content and isinstance(tool_content, str):
1650
+ if tool_content.lstrip().lower().startswith("error"):
1651
+ for _e in flush_text():
1652
+ yield _e
1653
+ yield ev.ErrorOutput(tool_content)
1654
+
1655
+ if record:
1656
+ for _e in flush_text():
1657
+ yield _e
1658
+ yield ev.FileOp(
1659
+ record=record,
1660
+ full_output=(tool_content if isinstance(tool_content, str) else ""),
1661
+ call_id=tool_call_id,
1662
+ )
1663
+ elif tool_call_id and tool_call_id in displayed_tool_ids:
1664
+ already_error = (tool_name == "shell" and tool_status != "success") or (
1665
+ tool_content
1666
+ and isinstance(tool_content, str)
1667
+ and tool_content.lstrip().lower().startswith("error")
1668
+ )
1669
+ if not already_error:
1670
+ elapsed = None
1671
+ start = subagent_tracker.tool_call_start_times.pop(tool_call_id, None)
1672
+ if start is not None:
1673
+ elapsed = time.time() - start
1674
+ preview = format_tool_result_preview(tool_name, tool_content, tool_status, elapsed)
1675
+ if preview:
1676
+ yield ev.ToolResult(
1677
+ preview=preview,
1678
+ is_error=preview.startswith("✗"),
1679
+ full_output=(tool_content if isinstance(tool_content, str) else ""),
1680
+ call_id=tool_call_id,
1681
+ )
1682
+
1683
+
1684
+ async def _handle_tool_call_chunk(
1685
+ block: dict,
1686
+ *,
1687
+ is_main_agent: bool,
1688
+ namespace,
1689
+ subagent_type_for_ns,
1690
+ tool_call_buffers: dict,
1691
+ displayed_tool_ids: set,
1692
+ tool_call_to_name: dict,
1693
+ file_op_tracker,
1694
+ subagent_tracker: SubagentTracker,
1695
+ flush_text,
1696
+ preview_task_id: str | None = None,
1697
+ ) -> AsyncIterator[Any]:
1698
+ """Accumulate streamed tool-call args and emit a ToolCall when complete."""
1699
+ chunk_name = block.get("name")
1700
+ chunk_args = block.get("args")
1701
+ chunk_id = block.get("id")
1702
+ chunk_index = block.get("index")
1703
+
1704
+ if chunk_index is not None:
1705
+ # Scoped to the agent the chunk came from: parallel subagents each count
1706
+ # their tool calls from index 0, and sharing a buffer interleaved their args.
1707
+ buffer_key: Any = (preview_task_id or namespace, chunk_index)
1708
+ elif chunk_id is not None:
1709
+ buffer_key = chunk_id
1710
+ else:
1711
+ buffer_key = f"unknown-{len(tool_call_buffers)}"
1712
+
1713
+ buffer = tool_call_buffers.setdefault(
1714
+ buffer_key, {"name": None, "id": None, "args": None, "args_parts": []}
1715
+ )
1716
+ if chunk_name:
1717
+ buffer["name"] = chunk_name
1718
+ if chunk_id:
1719
+ buffer["id"] = chunk_id
1720
+
1721
+ if isinstance(chunk_args, dict):
1722
+ buffer["args"] = chunk_args
1723
+ buffer["args_parts"] = []
1724
+ elif isinstance(chunk_args, str):
1725
+ if chunk_args:
1726
+ parts: list[str] = buffer.setdefault("args_parts", [])
1727
+ if not parts or chunk_args != parts[-1]:
1728
+ parts.append(chunk_args)
1729
+ buffer["args"] = "".join(parts)
1730
+ elif chunk_args is not None:
1731
+ buffer["args"] = chunk_args
1732
+
1733
+ buffer_name = buffer.get("name")
1734
+ buffer_id = buffer.get("id")
1735
+ if buffer_name is None:
1736
+ return
1737
+
1738
+ parsed_args = buffer.get("args")
1739
+ if isinstance(parsed_args, str):
1740
+ if not parsed_args:
1741
+ return
1742
+ try:
1743
+ parsed_args = json.loads(parsed_args)
1744
+ except json.JSONDecodeError:
1745
+ return
1746
+ elif parsed_args is None:
1747
+ return
1748
+ if not isinstance(parsed_args, dict):
1749
+ parsed_args = {"value": parsed_args}
1750
+
1751
+ for _e in flush_text():
1752
+ yield _e
1753
+
1754
+ display_needed = False
1755
+ if buffer_id is not None:
1756
+ if buffer_id not in displayed_tool_ids:
1757
+ displayed_tool_ids.add(buffer_id)
1758
+ tool_call_to_name[buffer_id] = buffer_name
1759
+ subagent_tracker.tool_call_start_times[buffer_id] = time.time()
1760
+ file_op_tracker.start_operation(buffer_name, parsed_args, buffer_id)
1761
+ display_needed = True
1762
+ else:
1763
+ file_op_tracker.update_args(buffer_id, parsed_args)
1764
+ else:
1765
+ display_needed = True
1766
+ tool_call_buffers.pop(buffer_key, None)
1767
+
1768
+ # A `task` call spawns a subagent — it's rendered via the SubagentActivity
1769
+ # ("dispatched"/"completed") events below, so suppress the duplicate generic
1770
+ # ToolCall card that would otherwise show the raw subagent prompt.
1771
+ _is_subagent_dispatch = buffer_name == "task" and "subagent_type" in parsed_args
1772
+
1773
+ # ask_question / ask_user_question are surfaced as a QuestionModal via their
1774
+ # "question" interrupt, so don't also render a generic tool card for them.
1775
+ _is_question = buffer_name in ("ask_question", "ask_user_question")
1776
+
1777
+ if display_needed and not _is_question and not _is_subagent_dispatch:
1778
+ icon = TOOL_ICONS.get(buffer_name, "🔧")
1779
+ display_str = format_tool_display(buffer_name, parsed_args)
1780
+ if is_main_agent:
1781
+ yield ev.ToolCall(
1782
+ name=buffer_name,
1783
+ display_str=display_str,
1784
+ icon=icon,
1785
+ is_main_agent=True,
1786
+ args=parsed_args,
1787
+ call_id=buffer_id,
1788
+ )
1789
+ elif namespace:
1790
+ # Track the subagent's tool call for the completion summary, but do
1791
+ # NOT emit a live SubagentActivity("tool_start") event — per-tool
1792
+ # cards clutter the UI. The dispatched/completed events are enough.
1793
+ subagent_tracker.record_tool_call(
1794
+ namespace,
1795
+ subagent_type_for_ns,
1796
+ buffer_name,
1797
+ parsed_args,
1798
+ TOOL_CATEGORIES,
1799
+ )
1800
+ if preview_task_id:
1801
+ yield ev.SubagentPreview(
1802
+ preview_task_id, "tool", f"{icon} {display_str}", buffer_id or ""
1803
+ )
1804
+
1805
+ if buffer_name == "task" and "subagent_type" in parsed_args:
1806
+ subagent_type = parsed_args["subagent_type"]
1807
+ description = parsed_args.get("description", "")
1808
+ if subagent_tracker.dispatch_subagent(buffer_id, subagent_type, description):
1809
+ yield ev.SubagentActivity(
1810
+ kind="dispatched",
1811
+ subagent_type=subagent_type,
1812
+ message=status_phrases.status_line("subagent", subagent_type),
1813
+ detail=description or None,
1814
+ color=get_agent_color(subagent_type),
1815
+ call_id=buffer_id,
1816
+ )
1817
+ # The panel's row for the same dispatch. A blocking task call is its
1818
+ # own one-row phase: it has no fan-out to belong to.
1819
+ yield task_from_sync_dispatch(
1820
+ buffer_id, subagent_type, description, time.time()
1821
+ )