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,762 @@
1
+ from __future__ import annotations
2
+
3
+ import codecs
4
+ import contextlib
5
+ import logging
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import threading
10
+ import time
11
+ import uuid
12
+ from collections import deque
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import IO, Literal
17
+
18
+ from .background_runner import (
19
+ BackgroundProcessSpawn,
20
+ BackgroundShellRunner,
21
+ BackgroundTerminationMode,
22
+ )
23
+ from .sandbox_settings import ShellSandboxSettings
24
+ from .terminal_ownership import TerminalOwnershipHandle, TerminalOwnershipLedger
25
+
26
+ _LOGGER = logging.getLogger("alysis_code.terminal_manager")
27
+ _STREAM_READ_CHUNK_SIZE = 8192
28
+ _POST_SIGKILL_WAIT_TIMEOUT_S = 1.0
29
+
30
+ ProcessStatus = Literal["running", "exited", "killed", "failed"]
31
+ OutputStream = Literal["stdout", "stderr"]
32
+ WaitUntil = Literal["output_available", "process_exited", "either"]
33
+ _TERMINAL_STATUSES: set[ProcessStatus] = {"exited", "killed", "failed"}
34
+
35
+
36
+ class TerminalLimitError(RuntimeError):
37
+ pass
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class OutputLine:
42
+ seq: int
43
+ stream: OutputStream
44
+ text: str
45
+ ts: float
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ProcessOutputSnapshot:
50
+ process_id: str
51
+ status: ProcessStatus
52
+ exit_code: int | None
53
+ failure_reason: str | None
54
+ lines: tuple[OutputLine, ...]
55
+ next_seq: int
56
+ dropped_lines: int
57
+ started_at_wall: float
58
+ runtime_s: float
59
+ total_bytes: int
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ProcessSummary:
64
+ process_id: str
65
+ cmd: str
66
+ cwd: Path
67
+ status: ProcessStatus
68
+ exit_code: int | None
69
+ runtime_s: float
70
+ started_at_wall: float
71
+
72
+
73
+ class _OutputBuffer:
74
+ def __init__(self, *, max_lines: int, max_bytes: int) -> None:
75
+ if max_lines <= 0:
76
+ raise ValueError("max_lines must be positive")
77
+ if max_bytes <= 0:
78
+ raise ValueError("max_bytes must be positive")
79
+ self._lines: deque[OutputLine] = deque(maxlen=max_lines)
80
+ self._max_lines = max_lines
81
+ self._max_bytes = max_bytes
82
+ self._current_bytes = 0
83
+ self._total_bytes = 0
84
+ self._next_seq = 1
85
+ self._dropped_lines = 0
86
+ self._lock = threading.Lock()
87
+
88
+ def append_line(self, *, stream: OutputStream, text: str) -> None:
89
+ encoded_len = len(text.encode("utf-8", errors="replace"))
90
+ with self._lock:
91
+ if len(self._lines) >= self._max_lines:
92
+ self._drop_oldest_locked()
93
+ line = OutputLine(
94
+ seq=self._next_seq,
95
+ stream=stream,
96
+ text=text,
97
+ ts=time.time(),
98
+ )
99
+ self._next_seq += 1
100
+ self._lines.append(line)
101
+ self._current_bytes += encoded_len
102
+ self._total_bytes += encoded_len
103
+ while self._current_bytes > self._max_bytes and self._lines:
104
+ self._drop_oldest_locked()
105
+
106
+ def snapshot_since(self, since: int) -> tuple[tuple[OutputLine, ...], int, int, int]:
107
+ with self._lock:
108
+ lines = tuple(line for line in self._lines if line.seq > since)
109
+ last_seq = self._next_seq - 1
110
+ return lines, last_seq, self._dropped_lines, self._total_bytes
111
+
112
+ def _drop_oldest_locked(self) -> None:
113
+ dropped = self._lines.popleft()
114
+ self._current_bytes -= len(dropped.text.encode("utf-8", errors="replace"))
115
+ self._dropped_lines += 1
116
+
117
+
118
+ class BackgroundProcess:
119
+ def __init__(
120
+ self,
121
+ *,
122
+ spawn: BackgroundProcessSpawn,
123
+ cmd: str,
124
+ cwd: Path,
125
+ output_max_lines: int,
126
+ output_max_bytes: int,
127
+ ownership_ledger: TerminalOwnershipLedger | None = None,
128
+ ) -> None:
129
+ self.process_id = uuid.uuid4().hex[:12]
130
+ self.cmd = cmd
131
+ self.cwd = cwd
132
+ self.started_at = time.perf_counter()
133
+ self.created_at_wall = time.time()
134
+ self.status: ProcessStatus = "running"
135
+ self.exit_code: int | None = None
136
+ self.failure_reason: str | None = None
137
+ self.started_argv = spawn.started_argv
138
+
139
+ self._popen = spawn.popen
140
+ self._cleanup = spawn.cleanup
141
+ self._termination_mode: BackgroundTerminationMode = spawn.termination_mode
142
+ self._output = _OutputBuffer(max_lines=output_max_lines, max_bytes=output_max_bytes)
143
+ self._condition = threading.Condition()
144
+ self._cleanup_lock = threading.Lock()
145
+ self._kill_lock = threading.Lock()
146
+ self._cleanup_called = False
147
+ self._kill_requested = False
148
+ self._failure_requested = False
149
+ self._ended_at: float | None = None
150
+ self._posix_pgid = (
151
+ _resolve_posix_process_group_id(self._popen)
152
+ if self._termination_mode == "process_group"
153
+ else None
154
+ )
155
+ self._ownership_ledger = ownership_ledger
156
+ self._ownership_handle: TerminalOwnershipHandle | None = None
157
+ if ownership_ledger is not None:
158
+ self._ownership_handle = ownership_ledger.track(
159
+ child_pid=self._popen.pid,
160
+ termination_mode=self._termination_mode,
161
+ posix_pgid=self._posix_pgid,
162
+ cleanup_kind=spawn.orphan_cleanup_kind,
163
+ cleanup_id=spawn.orphan_cleanup_id,
164
+ )
165
+ self._stdout_done = threading.Event()
166
+ self._stderr_done = threading.Event()
167
+
168
+ self._stdout_reader = threading.Thread(
169
+ target=self._read_stream,
170
+ name=f"alysis-bg-{self.process_id}-stdout",
171
+ args=("stdout", self._popen.stdout, self._stdout_done),
172
+ daemon=True,
173
+ )
174
+ self._stderr_reader = threading.Thread(
175
+ target=self._read_stream,
176
+ name=f"alysis-bg-{self.process_id}-stderr",
177
+ args=("stderr", self._popen.stderr, self._stderr_done),
178
+ daemon=True,
179
+ )
180
+ self._waiter = threading.Thread(
181
+ target=self._wait_for_process,
182
+ name=f"alysis-bg-{self.process_id}-waiter",
183
+ daemon=True,
184
+ )
185
+ self._stdout_reader.start()
186
+ self._stderr_reader.start()
187
+ self._waiter.start()
188
+
189
+ def read(self, since: int = 0) -> ProcessOutputSnapshot:
190
+ lines, next_seq, dropped_lines, total_bytes = self._output.snapshot_since(since)
191
+ with self._condition:
192
+ status = self.status
193
+ exit_code = self.exit_code
194
+ failure_reason = self.failure_reason
195
+ runtime_s = self._runtime_s_locked()
196
+ return ProcessOutputSnapshot(
197
+ process_id=self.process_id,
198
+ status=status,
199
+ exit_code=exit_code,
200
+ failure_reason=failure_reason,
201
+ lines=lines,
202
+ next_seq=next_seq,
203
+ dropped_lines=dropped_lines,
204
+ started_at_wall=self.created_at_wall,
205
+ runtime_s=runtime_s,
206
+ total_bytes=total_bytes,
207
+ )
208
+
209
+ def wait_for_output(
210
+ self,
211
+ *,
212
+ since: int = 0,
213
+ timeout_s: float = 0.0,
214
+ until: WaitUntil = "either",
215
+ ) -> tuple[ProcessOutputSnapshot, bool]:
216
+ if since < 0:
217
+ raise ValueError("since must be non-negative")
218
+ if until not in {"output_available", "process_exited", "either"}:
219
+ raise ValueError(f"unsupported wait condition: {until}")
220
+ deadline = time.perf_counter() + max(0.0, float(timeout_s))
221
+ while True:
222
+ snapshot = self.read(since)
223
+ has_output = bool(snapshot.lines)
224
+ exited = snapshot.status in _TERMINAL_STATUSES
225
+ if until == "output_available" and has_output:
226
+ return snapshot, False
227
+ if until == "process_exited" and exited:
228
+ return snapshot, False
229
+ if until == "either" and (has_output or exited):
230
+ return snapshot, False
231
+ remaining = deadline - time.perf_counter()
232
+ if remaining <= 0:
233
+ return snapshot, True
234
+ with self._condition:
235
+ self._condition.wait(remaining)
236
+
237
+ def kill(self, timeout_s: float) -> ProcessOutputSnapshot:
238
+ with self._kill_lock:
239
+ with self._condition:
240
+ if self._is_terminal_locked():
241
+ already_terminal = True
242
+ else:
243
+ already_terminal = False
244
+ self._kill_requested = True
245
+ if already_terminal:
246
+ return self.read()
247
+
248
+ self._send_terminate()
249
+ if not self.wait_for_exit(timeout_s):
250
+ self._send_kill()
251
+ self.wait_for_exit(_POST_SIGKILL_WAIT_TIMEOUT_S)
252
+ return self.read()
253
+
254
+ def wait_for_exit(self, timeout_s: float | None) -> bool:
255
+ deadline = None if timeout_s is None else time.perf_counter() + max(timeout_s, 0.0)
256
+ with self._condition:
257
+ while not self._is_terminal_locked():
258
+ if deadline is None:
259
+ self._condition.wait()
260
+ continue
261
+ remaining = deadline - time.perf_counter()
262
+ if remaining <= 0:
263
+ return False
264
+ self._condition.wait(remaining)
265
+ return True
266
+
267
+ def is_terminal(self) -> bool:
268
+ with self._condition:
269
+ return self._is_terminal_locked()
270
+
271
+ def is_terminal_older_than(self, older_than_s: float) -> bool:
272
+ with self._condition:
273
+ if not self._is_terminal_locked() or self._ended_at is None:
274
+ return False
275
+ return time.perf_counter() - self._ended_at >= older_than_s
276
+
277
+ def summary(self) -> ProcessSummary:
278
+ with self._condition:
279
+ return ProcessSummary(
280
+ process_id=self.process_id,
281
+ cmd=_truncate_command(self.cmd),
282
+ cwd=self.cwd,
283
+ status=self.status,
284
+ exit_code=self.exit_code,
285
+ runtime_s=self._runtime_s_locked(),
286
+ started_at_wall=self.created_at_wall,
287
+ )
288
+
289
+ def _read_stream(
290
+ self,
291
+ stream_name: OutputStream,
292
+ stream: IO[bytes] | None,
293
+ done: threading.Event,
294
+ ) -> None:
295
+ if stream is None:
296
+ done.set()
297
+ return
298
+ pending = ""
299
+ decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
300
+ try:
301
+ while True:
302
+ chunk = os.read(stream.fileno(), _STREAM_READ_CHUNK_SIZE)
303
+ if not chunk:
304
+ break
305
+ pending = self._append_decoded_chunk(
306
+ stream_name=stream_name,
307
+ pending=pending,
308
+ decoded=decoder.decode(chunk),
309
+ )
310
+ pending = self._append_decoded_chunk(
311
+ stream_name=stream_name,
312
+ pending=pending,
313
+ decoded=decoder.decode(b"", final=True),
314
+ )
315
+ if pending:
316
+ self._append_output_line(stream=stream_name, text=pending)
317
+ except OSError as exc:
318
+ if not self._kill_requested:
319
+ self._fail_reader_and_request_kill(f"{stream_name} reader failed: {exc}")
320
+ _LOGGER.exception(
321
+ "Background process %s %s reader failed",
322
+ self.process_id,
323
+ stream_name,
324
+ )
325
+ except Exception as exc: # noqa: BLE001
326
+ # Reader threads must never die silently; surface the failure in process state.
327
+ self._fail_reader_and_request_kill(f"{stream_name} reader failed: {exc}")
328
+ _LOGGER.exception(
329
+ "Background process %s %s reader crashed",
330
+ self.process_id,
331
+ stream_name,
332
+ )
333
+ finally:
334
+ with contextlib.suppress(OSError):
335
+ stream.close()
336
+ done.set()
337
+
338
+ def _append_decoded_chunk(
339
+ self,
340
+ *,
341
+ stream_name: OutputStream,
342
+ pending: str,
343
+ decoded: str,
344
+ ) -> str:
345
+ pending += decoded
346
+ while True:
347
+ newline_idx = pending.find("\n")
348
+ if newline_idx < 0:
349
+ return pending
350
+ line = pending[: newline_idx + 1]
351
+ pending = pending[newline_idx + 1 :]
352
+ self._append_output_line(stream=stream_name, text=line)
353
+
354
+ def _append_output_line(self, *, stream: OutputStream, text: str) -> None:
355
+ self._output.append_line(stream=stream, text=text)
356
+ with self._condition:
357
+ self._condition.notify_all()
358
+
359
+ def _wait_for_process(self) -> None:
360
+ try:
361
+ exit_code = self._popen.wait()
362
+ except Exception as exc: # noqa: BLE001
363
+ # The waiter is the only code path allowed to reap the child process.
364
+ cleanup_reason = self._run_cleanup_once()
365
+ reason = f"process wait failed: {exc}"
366
+ if cleanup_reason:
367
+ reason = f"{reason}; {cleanup_reason}"
368
+ _LOGGER.exception("Background process %s waiter failed", self.process_id)
369
+ self._publish_terminal_status(
370
+ status="failed",
371
+ exit_code=None,
372
+ failure_reason=reason,
373
+ )
374
+ return
375
+
376
+ self._stdout_done.wait()
377
+ self._stderr_done.wait()
378
+ cleanup_reason = self._run_cleanup_once()
379
+ if cleanup_reason is None and self._ownership_ledger is not None:
380
+ self._ownership_ledger.release(self._ownership_handle)
381
+
382
+ with self._condition:
383
+ self.exit_code = exit_code
384
+ if cleanup_reason:
385
+ self.failure_reason = cleanup_reason
386
+ self._failure_requested = True
387
+ if self._failure_requested:
388
+ self.status = "failed"
389
+ else:
390
+ self.status = "killed" if self._kill_requested else "exited"
391
+ if self._ended_at is None:
392
+ self._ended_at = time.perf_counter()
393
+ self._condition.notify_all()
394
+
395
+ def _fail_reader_and_request_kill(self, reason: str) -> None:
396
+ with self._condition:
397
+ self.failure_reason = reason
398
+ self._failure_requested = True
399
+ if self.status == "running":
400
+ self._kill_requested = True
401
+ else:
402
+ self._condition.notify_all()
403
+ return
404
+ self._send_kill()
405
+
406
+ def _send_terminate(self) -> None:
407
+ if self._termination_mode == "direct":
408
+ self._run_cleanup_and_record_failure()
409
+ _terminate_direct_popen(self._popen)
410
+ return
411
+
412
+ if os.name == "nt": # pragma: no cover - exercised on Windows
413
+ with contextlib.suppress(ProcessLookupError):
414
+ self._popen.send_signal(signal.CTRL_BREAK_EVENT)
415
+ return
416
+
417
+ if self._posix_pgid is None:
418
+ with contextlib.suppress(ProcessLookupError):
419
+ self._popen.terminate()
420
+ return
421
+ _signal_posix_process_group(self._posix_pgid, signal.SIGTERM)
422
+
423
+ def _send_kill(self) -> None:
424
+ if self._termination_mode == "direct":
425
+ self._run_cleanup_and_record_failure()
426
+ _kill_direct_popen(self._popen)
427
+ return
428
+
429
+ if os.name == "nt": # pragma: no cover - exercised on Windows
430
+ _kill_windows_process_tree(self._popen)
431
+ return
432
+
433
+ if self._posix_pgid is None:
434
+ with contextlib.suppress(ProcessLookupError):
435
+ self._popen.kill()
436
+ return
437
+ _signal_posix_process_group(self._posix_pgid, signal.SIGKILL)
438
+
439
+ def _run_cleanup_once(self) -> str | None:
440
+ cleanup: Callable[[], None] | None = None
441
+ with self._cleanup_lock:
442
+ if self._cleanup_called:
443
+ return None
444
+ self._cleanup_called = True
445
+ cleanup = self._cleanup
446
+ try:
447
+ cleanup()
448
+ return None
449
+ except Exception as exc: # noqa: BLE001
450
+ # Cleanup failures are preserved on the process and logged for operators.
451
+ _LOGGER.exception("Background process %s cleanup failed", self.process_id)
452
+ return f"process cleanup failed: {exc}"
453
+
454
+ def _run_cleanup_and_record_failure(self) -> None:
455
+ cleanup_reason = self._run_cleanup_once()
456
+ if cleanup_reason is None:
457
+ return
458
+ with self._condition:
459
+ self.failure_reason = cleanup_reason
460
+ self._failure_requested = True
461
+ self._condition.notify_all()
462
+
463
+ def _publish_terminal_status(
464
+ self,
465
+ *,
466
+ status: ProcessStatus,
467
+ exit_code: int | None,
468
+ failure_reason: str | None,
469
+ ) -> None:
470
+ with self._condition:
471
+ self.status = status
472
+ self.exit_code = exit_code
473
+ self.failure_reason = failure_reason
474
+ if status == "failed":
475
+ self._failure_requested = True
476
+ if self._ended_at is None:
477
+ self._ended_at = time.perf_counter()
478
+ self._condition.notify_all()
479
+
480
+ def _is_terminal_locked(self) -> bool:
481
+ return self.status in _TERMINAL_STATUSES
482
+
483
+ def _runtime_s_locked(self) -> float:
484
+ end = self._ended_at if self._ended_at is not None else time.perf_counter()
485
+ return max(0.0, end - self.started_at)
486
+
487
+
488
+ class TerminalManager:
489
+ def __init__(
490
+ self,
491
+ *,
492
+ runner: BackgroundShellRunner,
493
+ settings: ShellSandboxSettings,
494
+ ownership_ledger: TerminalOwnershipLedger | None = None,
495
+ ) -> None:
496
+ self._runner = runner
497
+ self._settings = settings
498
+ self._ownership_ledger = ownership_ledger or TerminalOwnershipLedger()
499
+ self._ownership_ledger.scavenge_stale()
500
+ self._processes: dict[str, BackgroundProcess] = {}
501
+ self._pending_starts = 0
502
+ self._lock = threading.Lock()
503
+
504
+ def start(
505
+ self,
506
+ *,
507
+ cmd: str,
508
+ cwd: Path,
509
+ root: Path,
510
+ env_overrides: dict[str, str] | None = None,
511
+ ) -> str:
512
+ with self._lock:
513
+ if not cmd.strip():
514
+ raise ValueError("cmd cannot be empty")
515
+
516
+ root_abs = root.resolve()
517
+ cwd_abs = cwd.resolve()
518
+ try:
519
+ cwd_abs.relative_to(root_abs)
520
+ except ValueError as exc:
521
+ raise ValueError(f"cwd escapes root: {cwd}") from exc
522
+
523
+ running_count = sum(
524
+ 1 for process in self._processes.values() if not process.is_terminal()
525
+ )
526
+ if running_count + self._pending_starts >= self._settings.background_max_concurrent:
527
+ raise TerminalLimitError(
528
+ "Maximum background process count reached "
529
+ f"({self._settings.background_max_concurrent})."
530
+ )
531
+ self._pending_starts += 1
532
+
533
+ spawn: BackgroundProcessSpawn | None = None
534
+ pending_released = False
535
+ try:
536
+ spawn = self._runner.start(
537
+ root=root_abs,
538
+ cwd=cwd_abs,
539
+ cmd=cmd,
540
+ env_overrides=env_overrides,
541
+ )
542
+ try:
543
+ process = BackgroundProcess(
544
+ spawn=spawn,
545
+ cmd=cmd,
546
+ cwd=cwd_abs,
547
+ output_max_lines=self._settings.background_output_max_lines,
548
+ output_max_bytes=self._settings.background_output_max_bytes,
549
+ ownership_ledger=self._ownership_ledger,
550
+ )
551
+ except BaseException:
552
+ with self._lock:
553
+ self._pending_starts -= 1
554
+ pending_released = True
555
+ _terminate_spawned_process(spawn)
556
+ raise
557
+ with self._lock:
558
+ self._processes[process.process_id] = process
559
+ self._pending_starts -= 1
560
+ pending_released = True
561
+ return process.process_id
562
+ except BaseException:
563
+ if not pending_released:
564
+ with self._lock:
565
+ self._pending_starts -= 1
566
+ raise
567
+
568
+ def read(self, process_id: str, *, since: int = 0) -> ProcessOutputSnapshot:
569
+ return self._get_process(process_id).read(since)
570
+
571
+ def wait_for_output(
572
+ self,
573
+ process_id: str,
574
+ *,
575
+ since: int = 0,
576
+ timeout_s: float = 0.0,
577
+ until: WaitUntil = "either",
578
+ ) -> tuple[ProcessOutputSnapshot, bool]:
579
+ return self._get_process(process_id).wait_for_output(
580
+ since=since,
581
+ timeout_s=timeout_s,
582
+ until=until,
583
+ )
584
+
585
+ def kill(self, process_id: str) -> ProcessOutputSnapshot:
586
+ process = self._get_process(process_id)
587
+ return process.kill(self._settings.background_kill_timeout_s)
588
+
589
+ def list(self) -> tuple[ProcessSummary, ...]:
590
+ with self._lock:
591
+ summaries = [process.summary() for process in self._processes.values()]
592
+ return tuple(sorted(summaries, key=lambda summary: summary.started_at_wall))
593
+
594
+ def shutdown_all(self, *, kill_timeout_s: float | None = None) -> None:
595
+ timeout_s = (
596
+ self._settings.background_kill_timeout_s if kill_timeout_s is None else kill_timeout_s
597
+ )
598
+ with self._lock:
599
+ processes = tuple(self._processes.values())
600
+ for process in processes:
601
+ if process.is_terminal():
602
+ continue
603
+ try:
604
+ process.kill(timeout_s)
605
+ except Exception: # noqa: BLE001
606
+ # Shutdown is best-effort across independent child processes.
607
+ _LOGGER.exception(
608
+ "Failed to shut down background process %s",
609
+ process.process_id,
610
+ )
611
+
612
+ def prune(self, *, older_than_s: float = 600.0) -> None:
613
+ with self._lock:
614
+ stale_ids = [
615
+ process_id
616
+ for process_id, process in self._processes.items()
617
+ if process.is_terminal_older_than(older_than_s)
618
+ ]
619
+ for process_id in stale_ids:
620
+ del self._processes[process_id]
621
+
622
+ def _get_process(self, process_id: str) -> BackgroundProcess:
623
+ with self._lock:
624
+ return self._processes[process_id]
625
+
626
+
627
+ def _truncate_command(cmd: str) -> str:
628
+ if len(cmd) <= 120:
629
+ return cmd
630
+ return f"{cmd[:117]}..."
631
+
632
+
633
+ def _resolve_posix_process_group_id(popen: subprocess.Popen[bytes]) -> int | None:
634
+ if os.name == "nt": # pragma: no cover - exercised on Windows
635
+ return None
636
+ pid = popen.pid
637
+ if pid <= 0:
638
+ return None
639
+ try:
640
+ return os.getpgid(pid)
641
+ except ProcessLookupError:
642
+ return pid
643
+
644
+
645
+ def _signal_posix_process_group(pgid: int, signum: int) -> bool:
646
+ try:
647
+ os.killpg(pgid, signum)
648
+ except ProcessLookupError:
649
+ return True
650
+ except OSError:
651
+ _LOGGER.exception("Failed to signal process group %s with signal %s", pgid, signum)
652
+ return False
653
+ return True
654
+
655
+
656
+ def _terminate_direct_popen(popen: subprocess.Popen[bytes]) -> None:
657
+ if popen.poll() is not None:
658
+ return
659
+ try:
660
+ popen.terminate()
661
+ except ProcessLookupError:
662
+ return
663
+ except OSError:
664
+ _LOGGER.exception("Failed to terminate background process %s", popen.pid)
665
+
666
+
667
+ def _kill_direct_popen(popen: subprocess.Popen[bytes]) -> None:
668
+ if popen.poll() is not None:
669
+ return
670
+ try:
671
+ popen.kill()
672
+ except ProcessLookupError:
673
+ return
674
+ except OSError:
675
+ _LOGGER.exception("Failed to kill background process %s", popen.pid)
676
+
677
+
678
+ def _kill_windows_process_tree(popen: subprocess.Popen[bytes]) -> None:
679
+ kwargs: dict[str, object] = {}
680
+ create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", None)
681
+ if create_no_window is not None:
682
+ kwargs["creationflags"] = create_no_window
683
+
684
+ try:
685
+ result = subprocess.run(
686
+ ["taskkill", "/PID", str(popen.pid), "/T", "/F"],
687
+ stdout=subprocess.DEVNULL,
688
+ stderr=subprocess.DEVNULL,
689
+ timeout=5.0,
690
+ check=False,
691
+ **kwargs,
692
+ )
693
+ except (OSError, subprocess.SubprocessError):
694
+ result = None
695
+
696
+ if result is not None and result.returncode == 0:
697
+ return
698
+
699
+ with contextlib.suppress(ProcessLookupError):
700
+ popen.kill()
701
+
702
+
703
+ def _terminate_direct_process(popen: subprocess.Popen[bytes], *, timeout_s: float) -> None:
704
+ _terminate_direct_popen(popen)
705
+ if not _wait_for_popen_exit(popen, timeout_s=timeout_s) and popen.poll() is None:
706
+ _kill_direct_popen(popen)
707
+ _wait_for_popen_exit(popen, timeout_s=_POST_SIGKILL_WAIT_TIMEOUT_S)
708
+
709
+
710
+ def _terminate_process_tree(popen: subprocess.Popen[bytes], *, timeout_s: float) -> None:
711
+ if os.name == "nt": # pragma: no cover - exercised on Windows
712
+ if popen.poll() is None:
713
+ with contextlib.suppress(ProcessLookupError):
714
+ popen.send_signal(signal.CTRL_BREAK_EVENT)
715
+ if not _wait_for_popen_exit(popen, timeout_s=timeout_s) and popen.poll() is None:
716
+ _kill_windows_process_tree(popen)
717
+ _wait_for_popen_exit(popen, timeout_s=_POST_SIGKILL_WAIT_TIMEOUT_S)
718
+ return
719
+
720
+ pgid = _resolve_posix_process_group_id(popen)
721
+ if popen.poll() is None:
722
+ if pgid is None:
723
+ with contextlib.suppress(ProcessLookupError):
724
+ popen.terminate()
725
+ else:
726
+ _signal_posix_process_group(pgid, signal.SIGTERM)
727
+ if not _wait_for_popen_exit(popen, timeout_s=timeout_s) and popen.poll() is None:
728
+ if pgid is None:
729
+ with contextlib.suppress(ProcessLookupError):
730
+ popen.kill()
731
+ else:
732
+ _signal_posix_process_group(pgid, signal.SIGKILL)
733
+ _wait_for_popen_exit(popen, timeout_s=_POST_SIGKILL_WAIT_TIMEOUT_S)
734
+
735
+
736
+ def _wait_for_popen_exit(popen: subprocess.Popen[bytes], *, timeout_s: float) -> bool:
737
+ try:
738
+ popen.wait(timeout=timeout_s)
739
+ except subprocess.TimeoutExpired:
740
+ return False
741
+ return True
742
+
743
+
744
+ def _terminate_spawned_process(spawn: BackgroundProcessSpawn) -> None:
745
+ if spawn.termination_mode == "direct":
746
+ try:
747
+ spawn.cleanup()
748
+ except Exception: # noqa: BLE001
749
+ _LOGGER.exception("Background process spawn cleanup failed after start error")
750
+ _terminate_direct_process(spawn.popen, timeout_s=1.0)
751
+ else:
752
+ _terminate_process_tree(spawn.popen, timeout_s=1.0)
753
+ try:
754
+ spawn.cleanup()
755
+ except Exception: # noqa: BLE001
756
+ _LOGGER.exception("Background process spawn cleanup failed after start error")
757
+ with contextlib.suppress(OSError):
758
+ if spawn.popen.stdout is not None:
759
+ spawn.popen.stdout.close()
760
+ with contextlib.suppress(OSError):
761
+ if spawn.popen.stderr is not None:
762
+ spawn.popen.stderr.close()