alysis-code 0.13.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 (458) hide show
  1. alysis_code/__init__.py +3 -0
  2. alysis_code/__main__.py +4 -0
  3. alysis_code/_build_info.py +14 -0
  4. alysis_code/account_login.py +468 -0
  5. alysis_code/agent/README.md +35 -0
  6. alysis_code/agent/__init__.py +11 -0
  7. alysis_code/agent/acceptance_contract.py +2217 -0
  8. alysis_code/agent/blast_radius.py +1403 -0
  9. alysis_code/agent/cache_keepalive.py +227 -0
  10. alysis_code/agent/completion_certificate.py +366 -0
  11. alysis_code/agent/completion_gate.py +306 -0
  12. alysis_code/agent/empty_response_stall.py +403 -0
  13. alysis_code/agent/errors.py +28 -0
  14. alysis_code/agent/llm_calls.py +475 -0
  15. alysis_code/agent/mutation_classification.py +227 -0
  16. alysis_code/agent/prompt_context.py +2508 -0
  17. alysis_code/agent/read_ledger.py +253 -0
  18. alysis_code/agent/regression_baseline.py +642 -0
  19. alysis_code/agent/reproduction_first.py +610 -0
  20. alysis_code/agent/sensitive_output.py +629 -0
  21. alysis_code/agent/session.py +3218 -0
  22. alysis_code/agent/steering.py +191 -0
  23. alysis_code/agent/subagent_execution.py +5177 -0
  24. alysis_code/agent/subagent_workspace.py +666 -0
  25. alysis_code/agent/tools_assembly.py +4728 -0
  26. alysis_code/agent/turn/__init__.py +101 -0
  27. alysis_code/agent/turn/core.py +8483 -0
  28. alysis_code/agent/turn/events.py +113 -0
  29. alysis_code/agent/turn/exploration.py +590 -0
  30. alysis_code/agent/turn/interventions.py +65 -0
  31. alysis_code/agent/turn/read_cache.py +420 -0
  32. alysis_code/agent/turn/snapshot.py +179 -0
  33. alysis_code/agent/turn_contract.py +661 -0
  34. alysis_code/agent/turn_path.py +129 -0
  35. alysis_code/agent/verification.py +2885 -0
  36. alysis_code/agent/verification_commands.py +512 -0
  37. alysis_code/agent/verification_evidence.py +738 -0
  38. alysis_code/agent_loop.py +693 -0
  39. alysis_code/agent_runtimes/__init__.py +51 -0
  40. alysis_code/agent_runtimes/base.py +114 -0
  41. alysis_code/agent_runtimes/builtins.py +129 -0
  42. alysis_code/agent_runtimes/codex_cli.py +664 -0
  43. alysis_code/agent_runtimes/host.py +263 -0
  44. alysis_code/agent_runtimes/registry.py +64 -0
  45. alysis_code/agent_runtimes/service.py +150 -0
  46. alysis_code/agentbox_client.py +416 -0
  47. alysis_code/agentbox_integration.py +310 -0
  48. alysis_code/alysis_cloud.py +152 -0
  49. alysis_code/approval_scope.py +276 -0
  50. alysis_code/assets/README.md +33 -0
  51. alysis_code/assets/__init__.py +126 -0
  52. alysis_code/assets/asset_read_core.py +281 -0
  53. alysis_code/assets/budget_allocator.py +456 -0
  54. alysis_code/assets/comprehender.py +759 -0
  55. alysis_code/assets/index.py +654 -0
  56. alysis_code/assets/ingestion.py +275 -0
  57. alysis_code/assets/legacy_migration.py +413 -0
  58. alysis_code/assets/models.py +263 -0
  59. alysis_code/assets/ocr.py +239 -0
  60. alysis_code/assets/owl/ascii/f-000.txt +13 -0
  61. alysis_code/assets/owl/ascii/f-001.txt +13 -0
  62. alysis_code/assets/owl/ascii/f-002.txt +13 -0
  63. alysis_code/assets/owl/ascii/f-003.txt +13 -0
  64. alysis_code/assets/owl/ascii/f-004.txt +13 -0
  65. alysis_code/assets/owl/ascii/f-005.txt +13 -0
  66. alysis_code/assets/owl/ascii/f-006.txt +13 -0
  67. alysis_code/assets/owl/ascii/f-007.txt +13 -0
  68. alysis_code/assets/owl/ascii/f-008.txt +13 -0
  69. alysis_code/assets/owl/ascii/f-009.txt +13 -0
  70. alysis_code/assets/owl/ascii/f-010.txt +13 -0
  71. alysis_code/assets/owl/ascii/f-011.txt +13 -0
  72. alysis_code/assets/owl/ascii/f-012.txt +13 -0
  73. alysis_code/assets/owl/ascii/f-013.txt +13 -0
  74. alysis_code/assets/owl/ascii/f-014.txt +13 -0
  75. alysis_code/assets/owl/ascii/f-015.txt +13 -0
  76. alysis_code/assets/owl/ascii/f-016.txt +13 -0
  77. alysis_code/assets/owl/ascii/f-017.txt +13 -0
  78. alysis_code/assets/owl/ascii/f-018.txt +13 -0
  79. alysis_code/assets/owl/ascii/f-019.txt +13 -0
  80. alysis_code/assets/owl/ascii/f-020.txt +13 -0
  81. alysis_code/assets/owl/index.html +98 -0
  82. alysis_code/assets/owl/show-owl.sh +761 -0
  83. alysis_code/assets/paths.py +49 -0
  84. alysis_code/assets/plan_binding.py +326 -0
  85. alysis_code/assets/planner_context.py +466 -0
  86. alysis_code/assets/planner_tools.py +184 -0
  87. alysis_code/assets/prompts.py +101 -0
  88. alysis_code/assets/replanner_context.py +239 -0
  89. alysis_code/assets/surface.py +521 -0
  90. alysis_code/assets/untrusted_content.py +48 -0
  91. alysis_code/assets/usage_logger.py +94 -0
  92. alysis_code/assets/worker_mirror.py +428 -0
  93. alysis_code/assets/worker_section.py +303 -0
  94. alysis_code/assets/worker_tools.py +468 -0
  95. alysis_code/atomic_io.py +83 -0
  96. alysis_code/auth_diagnostics.py +272 -0
  97. alysis_code/background_runner.py +366 -0
  98. alysis_code/branding.py +270 -0
  99. alysis_code/budget_policy.py +390 -0
  100. alysis_code/build_identity.py +465 -0
  101. alysis_code/builtin_hooks/__init__.py +7 -0
  102. alysis_code/builtin_hooks/notify_done_windows.py +65 -0
  103. alysis_code/bwrap_etc.py +76 -0
  104. alysis_code/cancellation.py +41 -0
  105. alysis_code/capabilities.py +137 -0
  106. alysis_code/chatgpt_codex_static_provider.py +133 -0
  107. alysis_code/cli.py +51 -0
  108. alysis_code/cli_impl/__init__.py +1 -0
  109. alysis_code/cli_impl/assets_cli.py +537 -0
  110. alysis_code/cli_impl/assets_modal.py +412 -0
  111. alysis_code/cli_impl/chat/__init__.py +156 -0
  112. alysis_code/cli_impl/chat/commands.py +2616 -0
  113. alysis_code/cli_impl/chat/loop.py +4508 -0
  114. alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
  115. alysis_code/cli_impl/chat/rendering.py +444 -0
  116. alysis_code/cli_impl/chat/state.py +124 -0
  117. alysis_code/cli_impl/chat_resume.py +830 -0
  118. alysis_code/cli_impl/chat_slash_completer.py +258 -0
  119. alysis_code/cli_impl/commands/__init__.py +11 -0
  120. alysis_code/cli_impl/commands/_shared.py +89 -0
  121. alysis_code/cli_impl/commands/auth.py +623 -0
  122. alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
  123. alysis_code/cli_impl/commands/chat_state.py +158 -0
  124. alysis_code/cli_impl/commands/chat_status.py +1248 -0
  125. alysis_code/cli_impl/commands/chat_terminal.py +942 -0
  126. alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
  127. alysis_code/cli_impl/commands/cli_common.py +1223 -0
  128. alysis_code/cli_impl/commands/cli_surface.py +77 -0
  129. alysis_code/cli_impl/commands/config.py +131 -0
  130. alysis_code/cli_impl/commands/conventions.py +85 -0
  131. alysis_code/cli_impl/commands/execution_helpers.py +350 -0
  132. alysis_code/cli_impl/commands/extensions.py +401 -0
  133. alysis_code/cli_impl/commands/forge.py +1282 -0
  134. alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
  135. alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
  136. alysis_code/cli_impl/commands/hooks.py +737 -0
  137. alysis_code/cli_impl/commands/ide_bridge.py +31 -0
  138. alysis_code/cli_impl/commands/mcp.py +700 -0
  139. alysis_code/cli_impl/commands/profile.py +453 -0
  140. alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
  141. alysis_code/cli_impl/commands/report.py +88 -0
  142. alysis_code/cli_impl/commands/root.py +1118 -0
  143. alysis_code/cli_impl/commands/sandbox.py +184 -0
  144. alysis_code/cli_impl/commands/server.py +54 -0
  145. alysis_code/cli_impl/commands/sessions.py +252 -0
  146. alysis_code/cli_impl/commands/skills.py +404 -0
  147. alysis_code/cli_impl/commands/startup.py +946 -0
  148. alysis_code/cli_impl/commands/tools.py +335 -0
  149. alysis_code/cli_impl/commands/update.py +364 -0
  150. alysis_code/cli_impl/commands/welcome.py +972 -0
  151. alysis_code/cli_impl/config_menu.py +3882 -0
  152. alysis_code/cli_impl/forge.py +4509 -0
  153. alysis_code/cli_impl/forge_recovery.py +485 -0
  154. alysis_code/cli_impl/setup_wizard.py +2409 -0
  155. alysis_code/cli_impl/tui/__init__.py +58 -0
  156. alysis_code/cli_impl/tui/app.py +4551 -0
  157. alysis_code/cli_impl/tui/config.py +32 -0
  158. alysis_code/cli_impl/tui/config_flow.py +2754 -0
  159. alysis_code/cli_impl/tui/config_overlay.py +566 -0
  160. alysis_code/cli_impl/tui/content.py +78 -0
  161. alysis_code/cli_impl/tui/footer.py +218 -0
  162. alysis_code/cli_impl/tui/forge_status.py +136 -0
  163. alysis_code/cli_impl/tui/markdown.py +244 -0
  164. alysis_code/cli_impl/tui/owl.py +109 -0
  165. alysis_code/cli_impl/tui/plan_meta.py +477 -0
  166. alysis_code/cli_impl/tui/setup_app.py +519 -0
  167. alysis_code/cli_impl/tui/setup_flow.py +1622 -0
  168. alysis_code/cli_impl/tui/state.py +101 -0
  169. alysis_code/cli_impl/tui/subagent_identity.py +66 -0
  170. alysis_code/cli_impl/tui/subagent_panel.py +186 -0
  171. alysis_code/cli_impl/tui/surface.py +796 -0
  172. alysis_code/cli_impl/tui/transcript.py +514 -0
  173. alysis_code/cli_impl/tui/update_prompt.py +79 -0
  174. alysis_code/cli_impl/tui/workspace_guard.py +384 -0
  175. alysis_code/clipboard.py +172 -0
  176. alysis_code/code_review.py +1211 -0
  177. alysis_code/compaction/__init__.py +28 -0
  178. alysis_code/compaction/conversation_compactor.py +2932 -0
  179. alysis_code/compaction/importance.py +177 -0
  180. alysis_code/compaction/settings.py +297 -0
  181. alysis_code/compaction/tool_output_offload.py +447 -0
  182. alysis_code/config.py +3509 -0
  183. alysis_code/conflict_auto_resolver.py +895 -0
  184. alysis_code/context/__init__.py +1 -0
  185. alysis_code/context/tool_schema_budgeter.py +220 -0
  186. alysis_code/crash_diagnostics.py +282 -0
  187. alysis_code/custom_tools/README.md +34 -0
  188. alysis_code/custom_tools/__init__.py +43 -0
  189. alysis_code/custom_tools/discovery.py +903 -0
  190. alysis_code/custom_tools/runtime.py +1516 -0
  191. alysis_code/custom_tools/session.py +227 -0
  192. alysis_code/custom_tools/trust.py +232 -0
  193. alysis_code/diff_paths.py +113 -0
  194. alysis_code/direction_change.py +293 -0
  195. alysis_code/dispatch_timing.py +306 -0
  196. alysis_code/durable_service_manager.py +1236 -0
  197. alysis_code/edit_discipline.py +659 -0
  198. alysis_code/error_text.py +73 -0
  199. alysis_code/execution_budget.py +411 -0
  200. alysis_code/execution_context.py +915 -0
  201. alysis_code/execution_deadline.py +1065 -0
  202. alysis_code/execution_shared.py +1904 -0
  203. alysis_code/extensions/README.md +30 -0
  204. alysis_code/extensions/__init__.py +93 -0
  205. alysis_code/extensions/activation.py +138 -0
  206. alysis_code/extensions/install.py +1436 -0
  207. alysis_code/extensions/manifest.py +487 -0
  208. alysis_code/extensions/models.py +74 -0
  209. alysis_code/extensions/paths.py +56 -0
  210. alysis_code/extensions/registry.json +4 -0
  211. alysis_code/extensions/registry.py +52 -0
  212. alysis_code/extensions/state.py +83 -0
  213. alysis_code/extensions/workspace_trust.py +101 -0
  214. alysis_code/failed_task_evidence.py +369 -0
  215. alysis_code/failure_category.py +315 -0
  216. alysis_code/feedback_report.py +1647 -0
  217. alysis_code/file_classification.py +485 -0
  218. alysis_code/forge.py +2064 -0
  219. alysis_code/forge_completion.py +362 -0
  220. alysis_code/forge_events.py +475 -0
  221. alysis_code/frontmatter_utils.py +95 -0
  222. alysis_code/git_evidence.py +1181 -0
  223. alysis_code/git_ops.py +560 -0
  224. alysis_code/git_safe.py +62 -0
  225. alysis_code/git_worktrees.py +190 -0
  226. alysis_code/hooks/README.md +33 -0
  227. alysis_code/hooks/__init__.py +67 -0
  228. alysis_code/hooks/audit.py +171 -0
  229. alysis_code/hooks/config.py +225 -0
  230. alysis_code/hooks/dispatcher.py +1110 -0
  231. alysis_code/hooks/models.py +447 -0
  232. alysis_code/hooks/trust.py +202 -0
  233. alysis_code/host_actions.py +543 -0
  234. alysis_code/host_browser.py +103 -0
  235. alysis_code/ide/__init__.py +5 -0
  236. alysis_code/ide/activity_events.py +399 -0
  237. alysis_code/ide/approvals.py +337 -0
  238. alysis_code/ide/artifacts.py +153 -0
  239. alysis_code/ide/browser_egress_proxy.py +1076 -0
  240. alysis_code/ide/cdp_websocket_transport.py +1192 -0
  241. alysis_code/ide/change_ledger.py +1721 -0
  242. alysis_code/ide/context_blocks.py +979 -0
  243. alysis_code/ide/event_stream.py +531 -0
  244. alysis_code/ide/forge_protocol.py +3112 -0
  245. alysis_code/ide/forge_request_ledger.py +737 -0
  246. alysis_code/ide/health.py +965 -0
  247. alysis_code/ide/managed_browser.py +2251 -0
  248. alysis_code/ide/management_protocol.py +3414 -0
  249. alysis_code/ide/mcp_oauth_coordinator.py +744 -0
  250. alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
  251. alysis_code/ide/prompt_queue.py +1070 -0
  252. alysis_code/ide/protocol.py +191 -0
  253. alysis_code/ide/resumable_swarm.py +1543 -0
  254. alysis_code/ide/session_search.py +295 -0
  255. alysis_code/ide/stdio_bridge.py +9935 -0
  256. alysis_code/ide/structured_state.py +1579 -0
  257. alysis_code/ide/swarm_protocol.py +816 -0
  258. alysis_code/integration_gate.py +506 -0
  259. alysis_code/interactive_input_guard.py +39 -0
  260. alysis_code/interactive_plan_mode.py +26 -0
  261. alysis_code/internal_artifacts.py +179 -0
  262. alysis_code/knowledge_base.py +1409 -0
  263. alysis_code/knowledge_capture.py +1190 -0
  264. alysis_code/knowledge_librarian.py +605 -0
  265. alysis_code/language_policy.py +34 -0
  266. alysis_code/litellm_static_provider.py +535 -0
  267. alysis_code/llm/__init__.py +1 -0
  268. alysis_code/llm/anthropic_messages.py +2288 -0
  269. alysis_code/llm/base.py +71 -0
  270. alysis_code/llm/cache_capabilities.py +985 -0
  271. alysis_code/llm/cache_control_blocks.py +244 -0
  272. alysis_code/llm/cache_policy.py +388 -0
  273. alysis_code/llm/factory.py +373 -0
  274. alysis_code/llm/gemini_generate_content.py +2652 -0
  275. alysis_code/llm/gemini_interactions.py +739 -0
  276. alysis_code/llm/metadata.py +450 -0
  277. alysis_code/llm/openai_compat.py +2947 -0
  278. alysis_code/llm/openai_responses.py +2604 -0
  279. alysis_code/llm/protocols.py +609 -0
  280. alysis_code/llm/provider_limits.py +525 -0
  281. alysis_code/llm/request_plan.py +389 -0
  282. alysis_code/llm/request_shape.py +238 -0
  283. alysis_code/llm/streaming.py +108 -0
  284. alysis_code/llm/temperature_compat.py +78 -0
  285. alysis_code/llm/types.py +195 -0
  286. alysis_code/llm/usage_normalization.py +222 -0
  287. alysis_code/llm_error_display.py +315 -0
  288. alysis_code/logging_redaction.py +326 -0
  289. alysis_code/managed_host_deadline.py +196 -0
  290. alysis_code/mcp/README.md +33 -0
  291. alysis_code/mcp/__init__.py +24 -0
  292. alysis_code/mcp/client.py +1137 -0
  293. alysis_code/mcp/config.py +597 -0
  294. alysis_code/mcp/errors.py +113 -0
  295. alysis_code/mcp/forge_scope.py +154 -0
  296. alysis_code/mcp/jsonrpc.py +214 -0
  297. alysis_code/mcp/manager.py +2308 -0
  298. alysis_code/mcp/models.py +666 -0
  299. alysis_code/mcp/oauth.py +972 -0
  300. alysis_code/mcp/oauth_runtime.py +310 -0
  301. alysis_code/mcp/oauth_store.py +276 -0
  302. alysis_code/mcp/prompts.py +329 -0
  303. alysis_code/mcp/resources.py +295 -0
  304. alysis_code/mcp/roots.py +106 -0
  305. alysis_code/mcp/server_requests.py +75 -0
  306. alysis_code/mcp/token_store.py +859 -0
  307. alysis_code/mcp/transport_http.py +1338 -0
  308. alysis_code/mcp/transport_stdio.py +1267 -0
  309. alysis_code/mcp/untrusted_content.py +119 -0
  310. alysis_code/merge_conflict_reviewer.py +729 -0
  311. alysis_code/model_catalog/__init__.py +1 -0
  312. alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
  313. alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
  314. alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
  315. alysis_code/model_metadata_policy.py +223 -0
  316. alysis_code/model_metadata_utils.py +103 -0
  317. alysis_code/model_registry.py +1420 -0
  318. alysis_code/model_router.py +147 -0
  319. alysis_code/permission_policy.py +1016 -0
  320. alysis_code/personas.py +451 -0
  321. alysis_code/pipeline_facts.py +233 -0
  322. alysis_code/plan_assistant.py +4763 -0
  323. alysis_code/plan_mode.py +393 -0
  324. alysis_code/plan_reconciliation.py +1228 -0
  325. alysis_code/plan_repair.py +652 -0
  326. alysis_code/plan_validation.py +1099 -0
  327. alysis_code/planning_constraints.py +904 -0
  328. alysis_code/policy.py +95 -0
  329. alysis_code/preview_server.py +457 -0
  330. alysis_code/process_reaping.py +566 -0
  331. alysis_code/profile_presets.py +1834 -0
  332. alysis_code/profiles.py +666 -0
  333. alysis_code/provider_auth/__init__.py +29 -0
  334. alysis_code/provider_auth/base.py +99 -0
  335. alysis_code/provider_auth/openai_codex.py +951 -0
  336. alysis_code/provider_auth/registry.py +76 -0
  337. alysis_code/provider_auth/store.py +125 -0
  338. alysis_code/provider_diagnostics.py +1209 -0
  339. alysis_code/provider_model_catalog.py +685 -0
  340. alysis_code/provider_telemetry.py +1699 -0
  341. alysis_code/provider_url.py +75 -0
  342. alysis_code/reasoning_contracts.py +911 -0
  343. alysis_code/remote_sync.py +350 -0
  344. alysis_code/replanning.py +1195 -0
  345. alysis_code/repo_scan.py +1152 -0
  346. alysis_code/request_estimation.py +296 -0
  347. alysis_code/review_gate.py +617 -0
  348. alysis_code/run_lock.py +1141 -0
  349. alysis_code/run_outcome.py +58 -0
  350. alysis_code/run_provenance.py +774 -0
  351. alysis_code/run_state.py +445 -0
  352. alysis_code/runtime_artifacts.py +116 -0
  353. alysis_code/runtime_context_features.py +78 -0
  354. alysis_code/runtime_kind.py +52 -0
  355. alysis_code/safety/__init__.py +11 -0
  356. alysis_code/safety/mcp_sanitize.py +29 -0
  357. alysis_code/safety/safe_http.py +297 -0
  358. alysis_code/safety/subagent_report.py +184 -0
  359. alysis_code/sandbox_doctor.py +682 -0
  360. alysis_code/sandbox_runner.py +1025 -0
  361. alysis_code/sandbox_settings.py +423 -0
  362. alysis_code/serialized_paths.py +355 -0
  363. alysis_code/server/__init__.py +3 -0
  364. alysis_code/server/app.py +367 -0
  365. alysis_code/server/auth.py +34 -0
  366. alysis_code/server/job_config.py +30 -0
  367. alysis_code/server/settings.py +215 -0
  368. alysis_code/server/store.py +193 -0
  369. alysis_code/server/worker_runner.py +657 -0
  370. alysis_code/service_persistence.py +355 -0
  371. alysis_code/session_artifacts.py +108 -0
  372. alysis_code/session_metrics.py +331 -0
  373. alysis_code/session_store.py +624 -0
  374. alysis_code/skills/README.md +34 -0
  375. alysis_code/skills/__init__.py +104 -0
  376. alysis_code/skills/conventions.py +84 -0
  377. alysis_code/skills/discovery.py +176 -0
  378. alysis_code/skills/eval_models.py +232 -0
  379. alysis_code/skills/eval_runner.py +372 -0
  380. alysis_code/skills/evals.py +1344 -0
  381. alysis_code/skills/install.py +293 -0
  382. alysis_code/skills/loader.py +118 -0
  383. alysis_code/skills/matching.py +103 -0
  384. alysis_code/skills/models.py +71 -0
  385. alysis_code/skills/paths.py +56 -0
  386. alysis_code/skills/prompting.py +500 -0
  387. alysis_code/skills/scaffold.py +142 -0
  388. alysis_code/skills/state.py +441 -0
  389. alysis_code/skills/transactions.py +125 -0
  390. alysis_code/skills/validation.py +304 -0
  391. alysis_code/step_budget.py +238 -0
  392. alysis_code/subagent_labels.py +49 -0
  393. alysis_code/subagents.py +1072 -0
  394. alysis_code/surface/__init__.py +80 -0
  395. alysis_code/surface/base.py +305 -0
  396. alysis_code/surface/console.py +387 -0
  397. alysis_code/surface/events.py +372 -0
  398. alysis_code/surface/hidden_surface.py +529 -0
  399. alysis_code/surface/noop_surface.py +219 -0
  400. alysis_code/surface/rich_surface.py +1555 -0
  401. alysis_code/surface/styles.py +67 -0
  402. alysis_code/surface/theme.py +455 -0
  403. alysis_code/surface/types.py +100 -0
  404. alysis_code/swarm_backend.py +926 -0
  405. alysis_code/swarm_orchestrator.py +4020 -0
  406. alysis_code/swarm_scheduler.py +441 -0
  407. alysis_code/swarm_trace.py +429 -0
  408. alysis_code/swarm_worker.py +2119 -0
  409. alysis_code/swarm_write_guard.py +348 -0
  410. alysis_code/task_dependencies.py +170 -0
  411. alysis_code/task_readiness.py +992 -0
  412. alysis_code/task_scope.py +2148 -0
  413. alysis_code/terminal_manager.py +762 -0
  414. alysis_code/terminal_ownership.py +460 -0
  415. alysis_code/text_normalization.py +30 -0
  416. alysis_code/token_budget.py +97 -0
  417. alysis_code/tools/README.md +34 -0
  418. alysis_code/tools/__init__.py +1 -0
  419. alysis_code/tools/artifacts.py +127 -0
  420. alysis_code/tools/availability.py +188 -0
  421. alysis_code/tools/fs.py +1456 -0
  422. alysis_code/tools/git.py +461 -0
  423. alysis_code/tools/history.py +229 -0
  424. alysis_code/tools/http_timeout.py +78 -0
  425. alysis_code/tools/image_generation.py +552 -0
  426. alysis_code/tools/registry.py +2936 -0
  427. alysis_code/tools/repo_map.py +476 -0
  428. alysis_code/tools/search.py +563 -0
  429. alysis_code/tools/shell.py +135 -0
  430. alysis_code/tools/symbols.py +1350 -0
  431. alysis_code/tools/test_discovery.py +643 -0
  432. alysis_code/tools/web.py +482 -0
  433. alysis_code/tools/web_search.py +2012 -0
  434. alysis_code/tools/web_search_dashscope.py +557 -0
  435. alysis_code/tools/web_search_ddgs.py +221 -0
  436. alysis_code/tools/web_search_provider_adapters.py +1429 -0
  437. alysis_code/tools/web_search_tavily.py +194 -0
  438. alysis_code/updates.py +933 -0
  439. alysis_code/usage_tracker.py +1990 -0
  440. alysis_code/verification_command_analysis.py +1004 -0
  441. alysis_code/verification_contract.py +574 -0
  442. alysis_code/verification_failure_summary.py +273 -0
  443. alysis_code/verification_repair.py +385 -0
  444. alysis_code/verify_gate.py +3129 -0
  445. alysis_code/web_research.py +1872 -0
  446. alysis_code/web_search_adapters.py +66 -0
  447. alysis_code/web_search_policy.py +27 -0
  448. alysis_code/workspace_binding.py +389 -0
  449. alysis_code/workspace_binding_ui.py +408 -0
  450. alysis_code/workspace_context.py +273 -0
  451. alysis_code/workspace_isolation.py +138 -0
  452. alysis_code/workspace_provisioning.py +455 -0
  453. alysis_code-0.13.0.dist-info/METADATA +507 -0
  454. alysis_code-0.13.0.dist-info/RECORD +458 -0
  455. alysis_code-0.13.0.dist-info/WHEEL +4 -0
  456. alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
  457. alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
  458. alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,796 @@
1
+ """``TuiSurface`` — renders agent events into the full-screen TUI transcript.
2
+
3
+ The agent runtime drives surfaces through the legacy ``on_*`` callbacks (same
4
+ path ``RichSurface`` uses), calling them synchronously from whatever thread runs
5
+ ``session.run_turn``. In the TUI that is a worker thread, so every handler just
6
+ mutates the thread-safe :class:`TuiTranscript` (which schedules a redraw); none
7
+ of them touch prompt_toolkit widgets directly.
8
+
9
+ Message/tool ``emit_*`` events are additive duplicates of the ``on_*`` calls and
10
+ are intentionally left undefined (defining no-op versions would double-render).
11
+ Error/warning/info are EITHER/OR in the runtime, so we provide real
12
+ ``emit_error``/``emit_warning``/``emit_info`` handlers -- otherwise the
13
+ runtime's capability probe would drop them. Info events are persistent
14
+ transcript entries; progress updates remain ephemeral status text.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ from collections.abc import Callable
21
+
22
+ from ...llm_error_display import friendly_llm_error_message
23
+ from ...subagent_labels import subagent_identity
24
+ from ...surface.types import (
25
+ ApprovalDecision,
26
+ ApprovalRequest,
27
+ PatchEvent,
28
+ StatusEvent,
29
+ SubagentEndEvent,
30
+ SubagentStartEvent,
31
+ ToolEndEvent,
32
+ ToolOutputEvent,
33
+ ToolStartEvent,
34
+ )
35
+ from .forge_status import FORGE_RUNNING_STATES, forge_status_counts
36
+ from .transcript import TuiTranscript
37
+
38
+ # Per-worker-thread cancellation token. The TUI sets this at the top of each turn
39
+ # worker; once the user soft-interrupts, that worker's token flips cancelled and
40
+ # every output handler below drops its events — so an abandoned turn (still blocked
41
+ # waiting on a slow model) can never paint into the transcript after the interrupt.
42
+ # It is thread-local, so a *new* turn on a fresh worker is unaffected.
43
+ _worker_ctx = threading.local()
44
+ _TRACE_LEVELS = frozenset({"off", "compact", "full"})
45
+
46
+
47
+ def _normalize_trace_level(value: object, *, fallback: str = "compact") -> str:
48
+ normalized = str(value or "").strip().lower()
49
+ return normalized if normalized in _TRACE_LEVELS else fallback
50
+
51
+
52
+ def set_active_cancellation(token: object | None) -> None:
53
+ _worker_ctx.token = token
54
+
55
+
56
+ def _worker_cancelled() -> bool:
57
+ token = getattr(_worker_ctx, "token", None)
58
+ return bool(token is not None and getattr(token, "is_cancelled", False))
59
+
60
+
61
+ def _tool_label(name: str) -> str:
62
+ try:
63
+ from ...tools.registry import tool_display_name
64
+
65
+ return tool_display_name(name)
66
+ except Exception:
67
+ return name
68
+
69
+
70
+ def _tool_detail(name: str, args: object) -> str:
71
+ """Short argument preview (search query, fetched URL, file path) so repeated
72
+ lines of the same tool stay distinguishable in the transcript. Shell-category
73
+ tools are excluded: full command lines can carry secrets and are not shown at
74
+ the default trace level."""
75
+ try:
76
+ from ...tools.registry import get_builtin_tool_metadata, tool_input_preview
77
+
78
+ spec = get_builtin_tool_metadata(name)
79
+ if spec is not None and "shell" in spec.categories:
80
+ return ""
81
+ detail = tool_input_preview(name, args if isinstance(args, dict) else {})
82
+ except Exception:
83
+ return ""
84
+ clean = " ".join(str(detail or "").split())
85
+ if clean in {"", "-"}:
86
+ return ""
87
+ if len(clean) > 64:
88
+ clean = clean[:63] + "…"
89
+ return clean
90
+
91
+
92
+ def _format_ms(elapsed_ms: int) -> str:
93
+ try:
94
+ elapsed_ms = int(elapsed_ms)
95
+ except (TypeError, ValueError):
96
+ return ""
97
+ if elapsed_ms < 1000:
98
+ return f"{elapsed_ms}ms"
99
+ return f"{elapsed_ms / 1000.0:.1f}s"
100
+
101
+
102
+ def _truncate(text: str, *, limit: int = 160) -> str:
103
+ clean = " ".join(str(text).split())
104
+ if len(clean) <= limit:
105
+ return clean
106
+ return clean[: limit - 1] + "…"
107
+
108
+
109
+ class TuiSurface:
110
+ """Surface implementation backed by a :class:`TuiTranscript`."""
111
+
112
+ renders_error_panel = True
113
+
114
+ def __init__(
115
+ self,
116
+ transcript: TuiTranscript,
117
+ *,
118
+ request_approval_ui: Callable[[ApprovalRequest], ApprovalDecision] | None = None,
119
+ on_hud_refresh: Callable[[], None] | None = None,
120
+ on_subagent_activity: Callable[[str | None], None] | None = None,
121
+ on_subagent_activities: Callable[[tuple[str, ...]], None] | None = None,
122
+ on_subagent_run_started: Callable[[SubagentStartEvent], None] | None = None,
123
+ ) -> None:
124
+ self._t = transcript
125
+ self._approval_ui = request_approval_ui
126
+ self._trace_level = "compact"
127
+ # Argument previews captured at tool start, keyed by call id, so the
128
+ # committed "✓ …" line can show what the tool actually worked on.
129
+ self._tool_details: dict[str, str] = {}
130
+ # Name of the tool whose "✓/↳" line was committed last, so consecutive
131
+ # runs of the same tool group as continuation rows instead of repeating
132
+ # the tool name (e.g. four "Search Web" lines).
133
+ self._last_tool_trace_name: str | None = None
134
+ # Live footer HUD (context/tokens/cost) refresh. The agent runtime drives
135
+ # the surface from the SAME worker thread that runs ``run_turn``, so this
136
+ # callback reads session state at quiescent points (tool-end / message-done)
137
+ # with no concurrent mutation. Throttled so a tool-heavy turn does not
138
+ # re-estimate the whole transcript on every step. Without it the footer would
139
+ # only update once the entire (possibly long, multi-step) turn completes,
140
+ # leaving the bottom-right numbers frozen while the agent is visibly working.
141
+ self._hud_refresh = on_hud_refresh
142
+ self._hud_last_refresh: float = 0.0
143
+ # Live Forge execution context (set for the duration of /execute plan): the
144
+ # run paths (to refresh task statuses from plan.json), a debounce stamp, the
145
+ # worker cancellation token, and the run id (to drop stale/superseded events).
146
+ self._forge_paths: object | None = None
147
+ self._forge_last_refresh: float = 0.0
148
+ self._forge_token: object | None = None
149
+ self._forge_run_id: str = ""
150
+ # Live subagent identity. The stack tracks concurrent subagent runs
151
+ # (start pushes, end pops); the callback tells the app which name to pin
152
+ # in the footer badge — None clears it. Entries are keyed by the pushing
153
+ # THREAD as well as the name: a run's start and end always fire on the
154
+ # same thread (the tool's run() is synchronous), while a stale end from
155
+ # an abandoned turn arrives on a thread that no longer owns an entry —
156
+ # so it can never pop a same-named agent the next turn just started.
157
+ # Cleared at every turn boundary and on soft-interrupt so an interrupted
158
+ # run can never leave a stale badge.
159
+ self._on_subagent_activity = on_subagent_activity
160
+ self._on_subagent_activities = on_subagent_activities
161
+ self._on_subagent_run_started = on_subagent_run_started
162
+ self._subagent_stack: list[tuple[int, str, str, str]] = []
163
+ self._subagent_activity: dict[str, str] = {}
164
+
165
+ @property
166
+ def trace_level(self) -> str:
167
+ return self._trace_level
168
+
169
+ @property
170
+ def reasoning_trace_enabled(self) -> bool:
171
+ """Safe provider summaries are requested only while trace is visible."""
172
+
173
+ return self._trace_level != "off"
174
+
175
+ def set_trace_level(self, level: str) -> str:
176
+ self._trace_level = _normalize_trace_level(level, fallback=self._trace_level)
177
+ self._t.set_trace_level(self._trace_level)
178
+ if self._trace_level == "off":
179
+ # A level change is display-only: close already-visible transient
180
+ # reasoning/status without touching the model request or transcript
181
+ # accounting state.
182
+ self._t.end_reasoning()
183
+ self._t.set_status(None)
184
+ return self._trace_level
185
+
186
+ def _maybe_refresh_hud(self) -> None:
187
+ """Refresh the footer HUD mid-turn (throttled), so context/tokens/cost tick
188
+ up while a multi-step turn runs instead of only at the end. Runs on the
189
+ worker thread; the callback (the caller's end-of-turn refresher) mutates the
190
+ shared ``TuiState`` and the next transcript redraw repaints the footer."""
191
+ fn = self._hud_refresh
192
+ if fn is None or _worker_cancelled():
193
+ return
194
+ import time
195
+
196
+ now = time.monotonic()
197
+ if now - self._hud_last_refresh < 0.4:
198
+ return
199
+ self._hud_last_refresh = now
200
+ try:
201
+ fn()
202
+ except Exception:
203
+ pass
204
+
205
+ # ------------------------------------------------------------------ content
206
+ def on_user_message(self, text: str) -> None:
207
+ # The app echoes the user's line instantly on submit; here we just open a
208
+ # fresh assistant block for the turn that is starting.
209
+ self._t.begin_turn()
210
+ # Turn boundary: drop argument previews stranded by an interrupted turn
211
+ # (a cancelled turn's on_tool_end returns before popping its entry).
212
+ self._tool_details.clear()
213
+ # And drop any subagent identity stranded the same way, so the footer
214
+ # badge can never claim a subagent from an abandoned turn is still active.
215
+ self.clear_subagent_activity()
216
+
217
+ def on_assistant_token(self, delta: str) -> None:
218
+ if _worker_cancelled():
219
+ return
220
+ self._t.stream_assistant(delta)
221
+
222
+ def on_reasoning_start(self, block_id: str) -> None:
223
+ """Open one provider-call-scoped safe-summary block."""
224
+
225
+ if _worker_cancelled() or self._trace_level == "off":
226
+ return
227
+ self._t.begin_reasoning(block_id)
228
+
229
+ def on_reasoning_token(self, delta: str) -> None:
230
+ # Provider adapters route only safe, provider-generated summaries here;
231
+ # raw, encrypted, and redacted reasoning never reaches the surface.
232
+ if _worker_cancelled() or self._trace_level == "off":
233
+ return
234
+ self._t.stream_reasoning(delta)
235
+
236
+ def on_reasoning_end(self, block_id: str) -> None:
237
+ if _worker_cancelled():
238
+ return
239
+ self._t.end_reasoning(block_id)
240
+
241
+ def reset_streamed_assistant(self) -> None:
242
+ # Transport retry restreams the reply; wipe the live block so the
243
+ # abandoned generation never shows doubled (see core._on_stream_restart).
244
+ self._t.restart_assistant()
245
+
246
+ def on_assistant_message_done(self, text: str) -> None:
247
+ if _worker_cancelled():
248
+ return
249
+ self._t.finish_assistant(text or "")
250
+ # A model response just landed (usage recorded) — refresh the live HUD so a
251
+ # multi-step turn's bottom-right numbers advance, not just at the very end.
252
+ self._maybe_refresh_hud()
253
+
254
+ def on_progress_update(self, message: str) -> None:
255
+ if _worker_cancelled() or self._trace_level == "off":
256
+ return
257
+ self._t.set_status(_truncate(message, limit=80) or None)
258
+
259
+ def on_status_update(self, status: StatusEvent) -> None:
260
+ # Static workspace/model/mode line — the footer already carries this.
261
+ return
262
+
263
+ # ------------------------------------------------------------- Forge swarm
264
+ def begin_forge(self, paths: object, token: object | None = None) -> None:
265
+ """Open the live Forge execution view from the current plan on disk.
266
+
267
+ Called by the worker just before ``run_swarm`` so the task table shows the
268
+ initial statuses; the swarm then refreshes them through
269
+ :meth:`on_swarm_event` and the final pass in :meth:`end_forge`. ``token`` is
270
+ the worker's cancellation token — checked in :meth:`on_swarm_event` (which
271
+ runs on the trace sink's OWN thread, where the thread-local
272
+ ``_worker_cancelled`` would not see it) so a soft-interrupt stops painting."""
273
+ self._forge_paths = paths
274
+ self._forge_token = token
275
+ self._forge_last_refresh = 0.0
276
+ self._forge_run_id = str(getattr(paths, "run_id", "") or "")
277
+ self._t.forge_begin(self._forge_run_id, list(self._read_plan_tasks()))
278
+
279
+ def _forge_run_cancelled(self) -> bool:
280
+ token = self._forge_token
281
+ return bool(token is not None and getattr(token, "is_cancelled", False))
282
+
283
+ def on_swarm_event(self, event: object) -> None:
284
+ """Receive a structured swarm trace event (preferred by the serialized
285
+ sink over :meth:`on_progress_update`): stream it as a trace/error line,
286
+ highlight the in-flight task, and refresh statuses from plan.json.
287
+
288
+ Runs on the trace sink's daemon thread, so it guards on the worker's token
289
+ (not the thread-local cancel flag), on the view still being open (a new turn
290
+ clears it), and on the event's run id matching — so a soft-interrupted or
291
+ superseded run can never paint stale progress."""
292
+ if self._forge_run_cancelled():
293
+ return
294
+ if self._t.forge_snapshot() is None:
295
+ return # the view was cleared (e.g. a new turn started)
296
+ event_run_id = str(getattr(event, "run_id", "") or "")
297
+ if event_run_id and self._forge_run_id and event_run_id != self._forge_run_id:
298
+ return # stale event from a previous run
299
+ phase = str(getattr(event, "phase", "") or "")
300
+ message = str(getattr(event, "message", "") or "")
301
+ task_id = getattr(event, "task_id", None)
302
+ task_id = str(task_id) if task_id else None
303
+ # Keep the run CLEAN: the live task table + the single moving phase line tell
304
+ # the story, so progress events are NOT spilled as a wall of trace lines.
305
+ # Only errors are surfaced as persistent lines (they matter and are rare).
306
+ if "error" in phase.lower():
307
+ prefix = f"[{task_id}] " if task_id else ""
308
+ self._t.append(
309
+ "error", f"✗ {_truncate(f'{prefix}{message}'.strip() or phase, limit=200)}"
310
+ )
311
+ self._t.forge_set_active(task_id, phase=phase, message=message)
312
+ self._maybe_refresh_forge_statuses()
313
+
314
+ def end_forge(self, summary: str = "", *, paths: object | None = None) -> None:
315
+ """Apply the final task statuses from disk (adding any tasks enrichment added
316
+ mid-run) and freeze the view as done, with a concise outcome summary.
317
+
318
+ ``paths`` re-arms the run paths for a late finalize: a soft-interrupt
319
+ clears ``self._forge_paths`` (via :meth:`interrupt_forge`) even though
320
+ the swarm cannot actually be stopped — when it then runs to its natural
321
+ end, the worker passes the run's paths back so the frozen table shows
322
+ the statuses the finished swarm really wrote instead of an empty view."""
323
+ if paths is not None:
324
+ self._forge_paths = paths
325
+ tasks = self._read_plan_tasks()
326
+ self._t.forge_sync_tasks(tasks)
327
+ done, failed, remaining = forge_status_counts(status for _tid, _title, status in tasks)
328
+ ok = failed == 0 and remaining == 0
329
+ if not summary:
330
+ if not tasks:
331
+ summary = "No execution-ready tasks."
332
+ else:
333
+ parts = [f"{done} done"]
334
+ if failed:
335
+ parts.append(f"{failed} failed")
336
+ if remaining:
337
+ parts.append(f"{remaining} remaining")
338
+ summary = "Done · " + " · ".join(parts) if ok else "Finished · " + " · ".join(parts)
339
+ self._t.forge_finish({tid: status for tid, _title, status in tasks}, summary, ok=ok)
340
+ self._forge_paths = None
341
+ self._forge_token = None
342
+ self._forge_run_id = ""
343
+
344
+ def interrupt_forge(self, summary: str = "Interrupted.") -> None:
345
+ """Mark any live in-progress Forge tasks interrupted and freeze the view."""
346
+ path = getattr(self._forge_paths, "plan_json_path", None)
347
+ updated: dict[str, str] = {}
348
+ if path is not None:
349
+ try:
350
+ import json
351
+ from datetime import datetime, timezone
352
+
353
+ data = json.loads(path.read_text(encoding="utf-8"))
354
+ changed = False
355
+ for task in data.get("tasks") or []:
356
+ if not isinstance(task, dict):
357
+ continue
358
+ status = str(task.get("status") or "").strip().lower()
359
+ task_id = str(task.get("id") or "").strip()
360
+ if not task_id or status not in FORGE_RUNNING_STATES:
361
+ continue
362
+ task["status"] = "interrupted"
363
+ task["last_error"] = "Interrupted by user."
364
+ updated[task_id] = "interrupted"
365
+ changed = True
366
+ if changed:
367
+ data["updated_at"] = datetime.now(timezone.utc).isoformat()
368
+ path.write_text(
369
+ json.dumps(data, indent=2, sort_keys=False) + "\n",
370
+ encoding="utf-8",
371
+ )
372
+ except Exception:
373
+ updated = {}
374
+ tasks = self._read_plan_tasks()
375
+ if tasks:
376
+ self._t.forge_sync_tasks(tasks)
377
+ status_map = {tid: status for tid, _title, status in tasks}
378
+ else:
379
+ status_map = dict(updated)
380
+ if status_map:
381
+ self._t.forge_update_statuses(status_map)
382
+ self._t.forge_finish(status_map, summary, ok=False)
383
+ self._forge_paths = None
384
+ self._forge_token = None
385
+ self._forge_run_id = ""
386
+
387
+ def append_system(self, text: str) -> None:
388
+ """Append captured handler output (warnings / summary) as one system block."""
389
+ clean = str(text or "").rstrip()
390
+ if clean:
391
+ self._t.append("system", clean)
392
+
393
+ def append_note(self, text: str, *, role: str = "system") -> None:
394
+ """Append a single status line with an explicit role.
395
+
396
+ Used for the ``/resume`` outcome line so the caller can pick a role that
397
+ flips the welcome→chat pane when needed (a resumed session with no visible
398
+ history leaves the transcript empty, where a ``system`` line stays hidden
399
+ behind the landing screen but an ``assistant`` line surfaces it)."""
400
+ clean = str(text or "").rstrip()
401
+ if clean:
402
+ self._t.append(str(role or "system"), clean)
403
+
404
+ def replace_history(self, messages: list[dict[str, object]]) -> None:
405
+ """Reload the transcript with a resumed conversation's history.
406
+
407
+ Called (on the UI thread, no turn in flight) after ``/resume`` swaps the
408
+ live session: the prior conversation's user/assistant turns replace the
409
+ current pane so the user actually "enters" the resumed session instead of
410
+ keeping the previous transcript. Tool calls/results are dropped by
411
+ :meth:`TuiTranscript.load_history` so the view stays clean."""
412
+ self._t.load_history(messages or [])
413
+
414
+ def _maybe_refresh_forge_statuses(self) -> None:
415
+ import time
416
+
417
+ now = time.monotonic()
418
+ if now - self._forge_last_refresh < 0.4:
419
+ return
420
+ self._forge_last_refresh = now
421
+ tasks = self._read_plan_tasks()
422
+ if tasks:
423
+ # Sync (not just update) so tasks added by plan enrichment mid-run show up.
424
+ self._t.forge_sync_tasks(tasks)
425
+
426
+ def _read_plan_tasks(self) -> list[tuple[str, str, str]]:
427
+ """Read ``(id, title, status)`` for each task from plan.json (best-effort).
428
+
429
+ Reads the raw JSON rather than the validating ``load_plan`` so a mid-run
430
+ refresh stays cheap and never raises while the swarm rewrites the file."""
431
+ paths = self._forge_paths
432
+ path = getattr(paths, "plan_json_path", None)
433
+ if path is None:
434
+ return []
435
+ try:
436
+ import json
437
+
438
+ data = json.loads(path.read_text(encoding="utf-8"))
439
+ except Exception:
440
+ return []
441
+ out: list[tuple[str, str, str]] = []
442
+ for task in data.get("tasks") or []:
443
+ if isinstance(task, dict) and task.get("id"):
444
+ out.append(
445
+ (
446
+ str(task["id"]),
447
+ str(task.get("title") or ""),
448
+ str(task.get("status") or "planned"),
449
+ )
450
+ )
451
+ return out
452
+
453
+ # -------------------------------------------------------------------- tools
454
+ def on_tool_start(self, event: ToolStartEvent) -> None:
455
+ # No "⚙ start" line — the live activity indicator under the question shows
456
+ # the running tool (with a spinner + elapsed); it commits to a "✓ …" line
457
+ # on completion. Collapse any open thinking first.
458
+ if _worker_cancelled():
459
+ return
460
+ label = _tool_label(event.name)
461
+ arg_detail = _tool_detail(event.name, event.args)
462
+ self._t.end_reasoning()
463
+ if self._trace_level == "off":
464
+ self._t.set_status("Working…")
465
+ return
466
+ if arg_detail and event.tool_call_id:
467
+ # Stale-entry backstop: evict oldest first, never in-flight wholesale.
468
+ # Stored for nested calls too, so a nested FAILURE's ✗ line can say
469
+ # which invocation failed (its success line is suppressed anyway).
470
+ while len(self._tool_details) > 256:
471
+ self._tool_details.pop(next(iter(self._tool_details)))
472
+ self._tool_details[event.tool_call_id] = arg_detail
473
+ if event.subagent_name and self._trace_level != "full":
474
+ # A nested subagent's tool call: the entry line already said who is
475
+ # working and on what, so its step-by-step chatter stays out of the
476
+ # transcript — the live status alone shows what the agent is doing
477
+ # right now. Trace "full" opts back into the committed lines.
478
+ detail = f" · {arg_detail}" if arg_detail else ""
479
+ display_name = self._current_subagent_display_name(str(event.subagent_name))
480
+ followed_label = self._current_subagent_follow_label(str(event.subagent_name))
481
+ activity = _truncate(f"{label}{detail}", limit=64)
482
+ self._subagent_activity[display_name] = activity
483
+ self._t.set_status(_truncate(f"↪ {followed_label} ▸ {activity}…", limit=80))
484
+ return
485
+ if self._trace_level == "full":
486
+ detail = f" · {arg_detail}" if arg_detail else ""
487
+ self._t.append("trace", f"▸ {label}{detail}")
488
+ self._last_tool_trace_name = None
489
+ # Keep the transient status short so it stays on one row on narrow
490
+ # terminals; the committed "✓" line carries the fuller preview.
491
+ status_detail = arg_detail if len(arg_detail) <= 40 else arg_detail[:39] + "…"
492
+ self._t.set_status(f"{label} · {status_detail}…" if status_detail else f"{label}…")
493
+
494
+ def on_tool_output(self, event: ToolOutputEvent) -> None:
495
+ return
496
+
497
+ def on_tool_end(self, event: ToolEndEvent) -> None:
498
+ # Pop before the cancellation check so an interrupted turn cannot strand
499
+ # the entry (and a reused call id can never show a stale preview).
500
+ arg_detail = self._tool_details.pop(event.tool_call_id, "")
501
+ if _worker_cancelled():
502
+ return
503
+ label = _tool_label(event.name)
504
+ if arg_detail:
505
+ label = f"{label} · {arg_detail}"
506
+ elapsed = _format_ms(event.elapsed_ms)
507
+ suffix = f" ({elapsed})" if elapsed else ""
508
+ if event.subagent_name and self._trace_level != "full":
509
+ # Nested subagent steps stay out of the transcript (minimal view) —
510
+ # except failures, which are real signal and keep their ✗ line,
511
+ # attributed to the agent that hit them. Successes just roll the
512
+ # live status back to "working" until the ↩ end line closes the run.
513
+ display_name = self._current_subagent_display_name(str(event.subagent_name))
514
+ followed_label = self._current_subagent_follow_label(str(event.subagent_name))
515
+ if event.status != "done":
516
+ meta = event.meta if isinstance(event.meta, dict) else {}
517
+ verdict = "approval declined" if meta.get("approval_declined") else "failed"
518
+ err = str(meta.get("error") or "")
519
+ detail = f": {_truncate(err)}" if err else ""
520
+ self._t.append("error", f"✗ {display_name} ▸ {label} {verdict}{suffix}{detail}")
521
+ if self._trace_level == "off":
522
+ # Off is quiet everywhere else — no named identity in the status
523
+ # (mirrors on_subagent_start's off branch and the pre-feature
524
+ # None-after-every-tool-end behavior).
525
+ self._t.set_status(None)
526
+ else:
527
+ outcome = "complete" if event.status == "done" else "failed"
528
+ activity = _truncate(f"{label} · {outcome}", limit=64)
529
+ self._subagent_activity[display_name] = activity
530
+ self._t.set_status(f"↪ {followed_label} · {activity}…")
531
+ self._maybe_refresh_hud()
532
+ return
533
+ if event.status == "done" and self._trace_level != "off":
534
+ # Consecutive successes of the SAME tool group under one header:
535
+ # ✓ Search Web · current date today (1.5s)
536
+ # ↳ today's date (352ms)
537
+ # The adjacency check keeps grouping honest — anything else appended
538
+ # in between (a message, an error, another tool) restarts a full line.
539
+ entries = self._t.entries
540
+ grouped = (
541
+ arg_detail != ""
542
+ and self._last_tool_trace_name == event.name
543
+ and bool(entries)
544
+ and entries[-1][0] == "trace"
545
+ and (entries[-1][1].startswith("✓") or entries[-1][1].startswith(" ↳"))
546
+ )
547
+ if grouped:
548
+ self._t.append("trace", f" ↳ {arg_detail}{suffix}")
549
+ else:
550
+ self._t.append("trace", f"✓ {label}{suffix}")
551
+ self._last_tool_trace_name = event.name
552
+ elif isinstance(event.meta, dict) and event.meta.get("approval_declined"):
553
+ err = str(event.meta.get("error") or "")
554
+ detail = f": {_truncate(err)}" if err else ""
555
+ self._t.append("error", f"✗ {label} approval declined{suffix}{detail}")
556
+ else:
557
+ err = ""
558
+ if isinstance(event.meta, dict):
559
+ err = str(event.meta.get("error") or "")
560
+ detail = f": {_truncate(err)}" if err else ""
561
+ self._t.append("error", f"✗ {label} failed{suffix}{detail}")
562
+ self._t.set_status(None)
563
+ # Between steps (no concurrent message mutation on this worker thread) is a
564
+ # safe point to advance the footer HUD as the turn progresses.
565
+ self._maybe_refresh_hud()
566
+
567
+ # --------------------------------------------------------------- subagents
568
+ def _sync_subagent_badge(self) -> None:
569
+ """Tell the app which live subagents to pin in the footer."""
570
+ names = tuple(
571
+ self._subagent_display_name(name, label, workspace_view)
572
+ for _thread_id, name, label, workspace_view in self._subagent_stack
573
+ )
574
+ fn = self._on_subagent_activity
575
+ if fn is not None:
576
+ try:
577
+ fn(names[-1] if names else None)
578
+ except Exception:
579
+ pass
580
+ all_fn = self._on_subagent_activities
581
+ if all_fn is not None:
582
+ try:
583
+ all_fn(names)
584
+ except Exception:
585
+ pass
586
+
587
+ @staticmethod
588
+ def _subagent_display_name(name: str, label: str, workspace_view: str) -> str:
589
+ marker = "[iso] " if workspace_view in {"isolated", "pinned"} else ""
590
+ return f"{marker}{subagent_identity(name, label)}"
591
+
592
+ def _current_subagent_display_name(self, name: str) -> str:
593
+ thread_id = threading.get_ident()
594
+ for owner_thread, candidate, label, workspace_view in reversed(self._subagent_stack):
595
+ if owner_thread == thread_id and candidate == name:
596
+ return self._subagent_display_name(candidate, label, workspace_view)
597
+ return name
598
+
599
+ @staticmethod
600
+ def _subagent_follow_label(name: str, label: str, workspace_view: str) -> str:
601
+ marker = "[iso] " if workspace_view in {"isolated", "pinned"} else ""
602
+ if label:
603
+ return f"{marker}{name} · following {label}"
604
+ return f"following {marker}{name}"
605
+
606
+ def _current_subagent_follow_label(self, name: str) -> str:
607
+ thread_id = threading.get_ident()
608
+ for owner_thread, candidate, label, workspace_view in reversed(self._subagent_stack):
609
+ if owner_thread == thread_id and candidate == name:
610
+ return self._subagent_follow_label(candidate, label, workspace_view)
611
+ return name
612
+
613
+ def clear_subagent_activity(self) -> None:
614
+ """Forget every live subagent and clear the footer badge.
615
+
616
+ Called at turn boundaries and on soft-interrupt. After this, the
617
+ abandoned runs' late end events find no matching stack entry and are
618
+ dropped whole — they cannot pop, restyle, or re-pin anything the next
619
+ turn owns."""
620
+ if self._subagent_stack:
621
+ self._subagent_stack.clear()
622
+ self._sync_subagent_badge()
623
+ self._subagent_activity.clear()
624
+
625
+ def on_subagent_start(self, event: SubagentStartEvent) -> None:
626
+ if _worker_cancelled():
627
+ return
628
+ run_started = self._on_subagent_run_started
629
+ if run_started is not None and event.subagent_run_id:
630
+ try:
631
+ run_started(event)
632
+ except Exception:
633
+ pass
634
+ # The badge is identity, not trace — it shows at every trace level.
635
+ name = str(event.name)
636
+ label = str(event.label or "")
637
+ workspace_view = str(event.workspace_view or "shared")
638
+ self._subagent_stack.append((threading.get_ident(), name, label, workspace_view))
639
+ self._sync_subagent_badge()
640
+ display_name = self._subagent_display_name(name, label, workspace_view)
641
+ followed_label = self._subagent_follow_label(name, label, workspace_view)
642
+ self._subagent_activity[display_name] = "starting"
643
+ if self._trace_level == "off":
644
+ return
645
+ # The spawn line carries stable identity only. Live activity is derived
646
+ # from the child's actual tool events below, never from canned role text.
647
+ self._t.append("subagent", f"↪ {display_name} · {event.mode}")
648
+ self._t.set_status(f"↪ {followed_label} · starting…")
649
+
650
+ def on_subagent_end(self, event: SubagentEndEvent) -> None:
651
+ # A run's start and end fire on the same thread, so the end may only
652
+ # unwind an entry this thread pushed. An end with no matching entry is
653
+ # stale — an abandoned turn's agent finishing late, or a start this
654
+ # surface never recorded — and must not touch the live turn's badge,
655
+ # status, or transcript at all.
656
+ entry = (
657
+ threading.get_ident(),
658
+ str(event.name),
659
+ str(event.label or ""),
660
+ str(event.workspace_view or "shared"),
661
+ )
662
+ matched = False
663
+ for index in range(len(self._subagent_stack) - 1, -1, -1):
664
+ if self._subagent_stack[index] == entry:
665
+ del self._subagent_stack[index]
666
+ matched = True
667
+ break
668
+ if not matched:
669
+ return
670
+ name = entry[1]
671
+ display_name = self._subagent_display_name(name, entry[2], entry[3])
672
+ if all(
673
+ self._subagent_display_name(stacked_name, label, workspace_view) != display_name
674
+ for _tid, stacked_name, label, workspace_view in self._subagent_stack
675
+ ):
676
+ self._subagent_activity.pop(display_name, None)
677
+ # Unwind the badge even for a cancelled worker — the matched pop only
678
+ # ever removes this thread's own entry, so re-syncing here can never
679
+ # resurrect a name the interrupt already dropped (clear_subagent_activity
680
+ # emptied the stack, making this pop unreachable for abandoned runs).
681
+ self._sync_subagent_badge()
682
+ if not self._subagent_stack:
683
+ # The last live agent is gone — clear the activity line at every
684
+ # trace level so an "↪ name · …" status can never outlive its run.
685
+ self._t.set_status(None)
686
+ if _worker_cancelled() or self._trace_level == "off":
687
+ return
688
+ status = "finished" if event.status == "success" else event.status
689
+ self._t.append(
690
+ "trace",
691
+ f"↩ {display_name} · {status} · {event.steps_completed} steps",
692
+ )
693
+ if self._subagent_stack:
694
+ # Roll the activity line to a survivor so the status is never
695
+ # attributed to an agent whose ↩ line just printed.
696
+ _owner, top_name, top_label, top_view = self._subagent_stack[-1]
697
+ top = self._subagent_display_name(top_name, top_label, top_view)
698
+ followed_label = self._subagent_follow_label(
699
+ top_name,
700
+ top_label,
701
+ top_view,
702
+ )
703
+ activity = self._subagent_activity.get(top, "starting")
704
+ self._t.set_status(f"↪ {followed_label} · {activity}…")
705
+
706
+ def on_patch_generated(self, event: PatchEvent) -> None:
707
+ if _worker_cancelled() or self._trace_level == "off":
708
+ return
709
+ files = ", ".join(event.files[:5]) or "patch"
710
+ self._t.append("trace", f"✎ patch · {files}")
711
+
712
+ # ---------------------------------------------------------------- diagnostics
713
+ def on_error(self, err: str) -> None:
714
+ if _worker_cancelled():
715
+ return
716
+ self._t.append("error", _truncate(friendly_llm_error_message(err), limit=480) or "error")
717
+ self._t.set_status(None)
718
+
719
+ def on_warning(self, warning: str) -> None:
720
+ if _worker_cancelled():
721
+ return
722
+ self._t.append("warn", _truncate(str(warning), limit=480) or "warning")
723
+
724
+ # ------------------------------------------------------------------ approval
725
+ def request_approval(self, request: ApprovalRequest) -> ApprovalDecision:
726
+ # Reaching this method already means the execution mode decided this
727
+ # action needs a human: ``fullaccess`` returns before requesting approval
728
+ # and ``readonly`` raises. So the surface never second-guesses the gate —
729
+ # it prompts, or fails closed when it cannot. (There used to be an
730
+ # auto-approve short-circuit here; it let a Shift+Tab silently turn
731
+ # ``review`` into ``fullaccess`` while the footer still read "safe".)
732
+ if _worker_cancelled():
733
+ # The user interrupted this (now-abandoned) turn; deny without prompting.
734
+ return ApprovalDecision(allow=False)
735
+ if self._approval_ui is not None:
736
+ try:
737
+ decision = self._approval_ui(request)
738
+ except Exception:
739
+ decision = None
740
+ if isinstance(decision, ApprovalDecision):
741
+ return decision
742
+ # No interactive approver reachable → fail closed.
743
+ self._t.append(
744
+ "warn",
745
+ f"Denied {request.kind}: approval required "
746
+ "(this mode gates the action, but no approval UI is available).",
747
+ )
748
+ return ApprovalDecision(allow=False)
749
+
750
+ # ----------------------------------------------------------------- emit path
751
+ # The agent dual-emits: message/tool events go through additive ``emit_*``
752
+ # helpers (skipped when absent) AND the legacy ``on_*`` calls we implement
753
+ # above — so we deliberately do NOT define ``emit_message_*`` /
754
+ # ``emit_tool_call_*`` (that would double-render). Error/warning/info, by
755
+ # contrast, are EITHER/OR: the runtime prefers a real (non-Noop) ``emit_*``
756
+ # and only falls back to ``on_*`` otherwise. We therefore define real
757
+ # delegating handlers so those never get silently dropped.
758
+ #
759
+ # Note: do not add a catch-all ``__getattr__`` here — a synthesized no-op
760
+ # ``emit_error``/``emit_warning`` looks "real" to the runtime's capability
761
+ # probe and would swallow every error/warning in the TUI.
762
+ def emit_error(
763
+ self,
764
+ code: str,
765
+ message: str,
766
+ recoverable: bool,
767
+ *,
768
+ worker_id: str | None = None,
769
+ role: str | None = None,
770
+ ) -> None:
771
+ prefix = f"{code}: " if code else ""
772
+ suffix = "" if recoverable else " (not recoverable)"
773
+ self.on_error(f"{prefix}{message}{suffix}")
774
+
775
+ def emit_warning(
776
+ self,
777
+ message: str,
778
+ *,
779
+ worker_id: str | None = None,
780
+ role: str | None = None,
781
+ ) -> None:
782
+ self.on_warning(str(message))
783
+
784
+ def emit_info(
785
+ self,
786
+ message: str,
787
+ *,
788
+ worker_id: str | None = None,
789
+ role: str | None = None,
790
+ ) -> None:
791
+ if _worker_cancelled():
792
+ return
793
+ self._t.append("info", _truncate(str(message), limit=480) or "info")
794
+
795
+
796
+ __all__ = ["TuiSurface", "set_active_cancellation"]