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,566 @@
1
+ """Tracked process-group lifecycle for agent-spawned shell commands.
2
+
3
+ A shell command that outlives its tool call keeps consuming the machine. The
4
+ observed failure mode is a timed-out test run: ``subprocess.run(timeout=...)``
5
+ kills the direct child (the shell) and returns, but the shell's *children* --
6
+ the pytest workers -- are never signalled and keep running. On a shared or
7
+ benchmarked host they then compete with whatever runs next.
8
+
9
+ The fix is ownership, not discovery. Every command the runner starts is placed
10
+ in its own POSIX process group (``start_new_session``), and the resulting pgid
11
+ is recorded here at spawn time. Reaping later signals *only* those recorded
12
+ pgids.
13
+
14
+ Hard safety rule, enforced structurally by this module:
15
+
16
+ * ``ProcessGroupRegistry`` can only learn a pgid through :meth:`register`,
17
+ which the runner calls with the pgid of a process it just created.
18
+ * Nothing here ever enumerates the process table, and nothing ever matches a
19
+ process by name, cmdline, or any other heuristic. There is no code path that
20
+ can signal a group the runner did not spawn.
21
+ * A recorded pgid is refused if it is not a positive integer, if it is this
22
+ process's own group (a defensive check for a host where session creation
23
+ silently failed), or if the group leader's start token changed since
24
+ registration. That last guard reads ``/proc``, so it is active on Linux and
25
+ absent on macOS/BSD; there the residual exposure is a pgid recycled in the
26
+ window between a tool call returning and the group being released, which is
27
+ the same window ``release_if_finished`` closes on the very next statement.
28
+
29
+ The reaping guarantee is scoped to the session that did the spawning: a session
30
+ never signals another session's groups, and an interactive session's own turn
31
+ never kills groups that session started. A nested delegation (subagent or
32
+ worker) still cleans up what *it* started when it ends -- its work is over,
33
+ and the groups belong to it.
34
+
35
+ Everything except the signalling helpers is pure and unit-testable without
36
+ spawning a process. The platform guard is a separate function so the tracking
37
+ and decision logic stay exercisable on any host, mirroring the PIPESTATUS
38
+ capture guard in :mod:`alysis_code.pipeline_facts`.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import os
44
+ import subprocess
45
+ import threading
46
+ import time
47
+ from collections.abc import Callable
48
+ from dataclasses import dataclass
49
+ from enum import StrEnum
50
+ from pathlib import Path
51
+ from typing import Any
52
+
53
+ from .branding import env_get
54
+ from .runtime_kind import RuntimeKind, normalize_runtime_kind
55
+
56
+ # Grace period between SIGTERM and SIGKILL for a tracked group.
57
+ PROCESS_REAP_GRACE_SECONDS = 5.0
58
+ _GROUP_EXIT_POLL_SECONDS = 0.02
59
+
60
+ SIGNAL_NONE = "none"
61
+ SIGNAL_TERM = "SIGTERM"
62
+ SIGNAL_KILL = "SIGKILL"
63
+
64
+
65
+ def process_groups_supported() -> bool:
66
+ """True when the host has POSIX process groups that can be signalled.
67
+
68
+ Windows has no ``killpg``; command tracking degrades to a no-op there. Kept
69
+ as a separate function so tests can exercise the tracking and reaping paths
70
+ on any host (mirrors ``_pipeline_capture_platform_ok`` in the shell tool).
71
+ """
72
+ return os.name != "nt"
73
+
74
+
75
+ def _process_reaping_enabled(cfg: Any | None) -> bool:
76
+ """Kill-switch for process reaping (step 5).
77
+
78
+ ``ALYSIS_PROCESS_REAPING`` (off/0/false/no/disabled) wins over the config
79
+ value; default is on. When off, commands still run in their own process
80
+ group but nothing is ever signalled and no reaping telemetry is emitted --
81
+ the legacy leave-it-running behaviour.
82
+ """
83
+ env_value = env_get("ALYSIS_PROCESS_REAPING")
84
+ if env_value is not None:
85
+ normalized = str(env_value).strip().lower()
86
+ if normalized in {"off", "0", "false", "no", "disabled"}:
87
+ return False
88
+ if normalized in {"on", "1", "true", "yes", "enabled"}:
89
+ return True
90
+ return bool(getattr(cfg, "process_reaping_enabled", True))
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Reaping decision (pure)
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ class ReapEvent(StrEnum):
99
+ TURN_FINALIZATION = "turn_finalization"
100
+ SESSION_CLOSE = "session_close"
101
+
102
+
103
+ class ReapAction(StrEnum):
104
+ REAP = "reap"
105
+ REPORT = "report"
106
+ SKIP = "skip"
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class ReapDecision:
111
+ action: ReapAction
112
+ reason: str
113
+
114
+
115
+ def _runtime_kind_is_interactive(kind: RuntimeKind | str | None) -> bool:
116
+ try:
117
+ normalized = normalize_runtime_kind(kind, fallback=RuntimeKind.INTERACTIVE_CHAT)
118
+ except Exception: # noqa: BLE001 - an unknown kind is treated as interactive (never auto-kill)
119
+ return True
120
+ return normalized is RuntimeKind.INTERACTIVE_CHAT
121
+
122
+
123
+ def resolve_reap_decision(
124
+ *,
125
+ runtime_kind: RuntimeKind | str | None,
126
+ event: ReapEvent | str,
127
+ enabled: bool,
128
+ ) -> ReapDecision:
129
+ """Decide what to do with tracked live groups at ``event``. Pure.
130
+
131
+ The table is deliberately small and asymmetric:
132
+
133
+ ================== ================== ==============
134
+ runtime kind turn finalization session close
135
+ ================== ================== ==============
136
+ interactive_chat report reap
137
+ everything else reap reap
138
+ ================== ================== ==============
139
+
140
+ An interactive turn is never auto-killed: a user may have asked for a dev
141
+ server and expects it to outlive the turn. Their still-running groups are
142
+ reported instead, and reaped when the session itself ends. Every autonomous
143
+ runtime kind (one-shot, forge, swarm worker, subagent, or conflict resolver)
144
+ declares itself done at turn end, so anything it left
145
+ running is a leak and is terminated.
146
+ """
147
+ if not enabled:
148
+ return ReapDecision(ReapAction.SKIP, "process_reaping_disabled")
149
+ normalized_event = (
150
+ event if isinstance(event, ReapEvent) else ReapEvent(str(event).strip().lower())
151
+ )
152
+ if normalized_event is ReapEvent.SESSION_CLOSE:
153
+ return ReapDecision(ReapAction.REAP, "session_close")
154
+ if _runtime_kind_is_interactive(runtime_kind):
155
+ return ReapDecision(ReapAction.REPORT, "interactive_turn_keeps_user_processes")
156
+ return ReapDecision(ReapAction.REAP, "autonomous_turn_finalization")
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Tracked groups
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class TrackedProcessGroup:
166
+ """One process group this runner created and has not seen exit."""
167
+
168
+ pgid: int
169
+ command: str
170
+ origin: str
171
+ started_at: float
172
+ start_token: str | None = None
173
+
174
+ def runtime_s(self, *, now: float | None = None) -> float:
175
+ current = time.monotonic() if now is None else now
176
+ return max(0.0, round(current - self.started_at, 3))
177
+
178
+ def payload(self, *, now: float | None = None) -> dict[str, Any]:
179
+ return {
180
+ "pgid": self.pgid,
181
+ "command": self.command,
182
+ "origin": self.origin,
183
+ "runtime_s": self.runtime_s(now=now),
184
+ }
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class ProcessReapOutcome:
189
+ pgid: int
190
+ command: str
191
+ origin: str
192
+ runtime_s: float
193
+ signal_used: str
194
+
195
+ def payload(self) -> dict[str, Any]:
196
+ return {
197
+ "pgid": self.pgid,
198
+ "command": self.command,
199
+ "origin": self.origin,
200
+ "runtime_s": self.runtime_s,
201
+ "signal_used": self.signal_used,
202
+ }
203
+
204
+
205
+ class ProcessGroupRegistry:
206
+ """Records the process groups this runner created.
207
+
208
+ This is the *only* source of pgids that may ever be signalled. It is
209
+ per-session, so a subagent never reaps its parent's groups and vice versa.
210
+ """
211
+
212
+ def __init__(self) -> None:
213
+ self._lock = threading.Lock()
214
+ self._groups: dict[int, TrackedProcessGroup] = {}
215
+
216
+ def register(self, *, pgid: int, command: str, origin: str) -> TrackedProcessGroup | None:
217
+ """Record a group this runner just created. Returns ``None`` if refused."""
218
+ if not _pgid_is_signalable(pgid):
219
+ return None
220
+ record = TrackedProcessGroup(
221
+ pgid=int(pgid),
222
+ command=str(command or ""),
223
+ origin=str(origin or "shell_run"),
224
+ started_at=time.monotonic(),
225
+ start_token=_pid_start_token(int(pgid)),
226
+ )
227
+ with self._lock:
228
+ self._groups[record.pgid] = record
229
+ return record
230
+
231
+ def release(self, pgid: int) -> None:
232
+ """Forget a group unconditionally (the caller knows it is finished)."""
233
+ with self._lock:
234
+ self._groups.pop(int(pgid), None)
235
+
236
+ def release_if_finished(self, pgid: int) -> bool:
237
+ """Drop the group when it no longer exists; keep it when it is alive.
238
+
239
+ Called when a tool call returns. A plain command that ran to completion
240
+ is dropped here; a command that timed out, was backgrounded with ``&``,
241
+ or spawned a daemon leaves a live group and stays tracked for reaping.
242
+ """
243
+ if not process_group_exists(int(pgid)):
244
+ self.release(pgid)
245
+ return True
246
+ return False
247
+
248
+ def tracked(self) -> tuple[TrackedProcessGroup, ...]:
249
+ with self._lock:
250
+ return tuple(self._groups.values())
251
+
252
+ def live(self) -> tuple[TrackedProcessGroup, ...]:
253
+ """Tracked groups that still exist, dropping any that have since exited."""
254
+ alive: list[TrackedProcessGroup] = []
255
+ for record in self.tracked():
256
+ if process_group_exists(record.pgid) and _start_token_matches(record):
257
+ alive.append(record)
258
+ else:
259
+ self.release(record.pgid)
260
+ return tuple(alive)
261
+
262
+ def clear(self) -> None:
263
+ with self._lock:
264
+ self._groups.clear()
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # POSIX signalling (the only impure part)
269
+ # ---------------------------------------------------------------------------
270
+
271
+
272
+ def _pgid_is_signalable(pgid: Any) -> bool:
273
+ """Reject anything that must never be signalled, before it is ever recorded."""
274
+ # Strict about the type: a pgid comes from os.getpgid, so anything that is
275
+ # not already an int reached this from somewhere it should not have, and
276
+ # coercing it would launder that mistake into a signal.
277
+ if not isinstance(pgid, int) or isinstance(pgid, bool):
278
+ return False
279
+ candidate = pgid
280
+ if candidate <= 1:
281
+ return False
282
+ if not process_groups_supported():
283
+ return False
284
+ try:
285
+ own_group = os.getpgrp()
286
+ except (AttributeError, OSError): # pragma: no cover - exercised on Windows
287
+ return False
288
+ # A pgid equal to our own group means session creation did not take effect;
289
+ # signalling it would kill the agent itself.
290
+ return candidate != own_group
291
+
292
+
293
+ def _pid_start_token(pid: int) -> str | None:
294
+ """Linux process start time, used to detect PID reuse. ``None`` elsewhere."""
295
+ stat_path = Path("/proc") / str(pid) / "stat"
296
+ try:
297
+ text = stat_path.read_text(encoding="utf-8")
298
+ except OSError:
299
+ return None
300
+ try:
301
+ after_comm = text.rsplit(") ", 1)[1]
302
+ fields = after_comm.split()
303
+ return f"linux:{fields[19]}"
304
+ except (IndexError, ValueError):
305
+ return None
306
+
307
+
308
+ def _start_token_matches(record: TrackedProcessGroup) -> bool:
309
+ """False when the group leader was replaced by an unrelated recycled PID."""
310
+ if record.start_token is None:
311
+ return True
312
+ current = _pid_start_token(record.pgid)
313
+ if current is None:
314
+ return True
315
+ return current == record.start_token
316
+
317
+
318
+ def resolve_process_group_id(pid: int) -> int | None:
319
+ """Process-group id of a process we started, or ``None`` when unavailable."""
320
+ if not process_groups_supported(): # pragma: no cover - exercised on Windows
321
+ return None
322
+ if pid <= 0:
323
+ return None
324
+ try:
325
+ return os.getpgid(pid)
326
+ except OSError:
327
+ # The leader already exited; with start_new_session its pid is the pgid.
328
+ return pid
329
+
330
+
331
+ def process_group_exists(pgid: int) -> bool:
332
+ if not process_groups_supported(): # pragma: no cover - exercised on Windows
333
+ return False
334
+ try:
335
+ os.killpg(pgid, 0)
336
+ except ProcessLookupError:
337
+ return False
338
+ except PermissionError:
339
+ return True
340
+ except OSError:
341
+ return False
342
+ return True
343
+
344
+
345
+ def signal_process_group(pgid: int, signum: int) -> bool:
346
+ if not process_groups_supported(): # pragma: no cover - exercised on Windows
347
+ return False
348
+ try:
349
+ os.killpg(pgid, signum)
350
+ except ProcessLookupError:
351
+ return True
352
+ except OSError:
353
+ return False
354
+ return True
355
+
356
+
357
+ def _wait_for_group_exit(pgid: int, *, timeout_s: float) -> bool:
358
+ deadline = time.monotonic() + max(0.0, timeout_s)
359
+ while time.monotonic() < deadline:
360
+ if not process_group_exists(pgid):
361
+ return True
362
+ time.sleep(_GROUP_EXIT_POLL_SECONDS)
363
+ return not process_group_exists(pgid)
364
+
365
+
366
+ def reap_process_group(
367
+ record: TrackedProcessGroup,
368
+ *,
369
+ grace_seconds: float = PROCESS_REAP_GRACE_SECONDS,
370
+ ) -> ProcessReapOutcome:
371
+ """SIGTERM, bounded grace, then SIGKILL. Only ever called for a tracked group."""
372
+ import signal as signal_module
373
+
374
+ runtime_s = record.runtime_s()
375
+ if not _pgid_is_signalable(record.pgid) or not _start_token_matches(record):
376
+ return ProcessReapOutcome(
377
+ pgid=record.pgid,
378
+ command=record.command,
379
+ origin=record.origin,
380
+ runtime_s=runtime_s,
381
+ signal_used=SIGNAL_NONE,
382
+ )
383
+ if not process_group_exists(record.pgid):
384
+ return ProcessReapOutcome(
385
+ pgid=record.pgid,
386
+ command=record.command,
387
+ origin=record.origin,
388
+ runtime_s=runtime_s,
389
+ signal_used=SIGNAL_NONE,
390
+ )
391
+ signal_process_group(record.pgid, signal_module.SIGTERM)
392
+ if _wait_for_group_exit(record.pgid, timeout_s=grace_seconds):
393
+ return ProcessReapOutcome(
394
+ pgid=record.pgid,
395
+ command=record.command,
396
+ origin=record.origin,
397
+ runtime_s=runtime_s,
398
+ signal_used=SIGNAL_TERM,
399
+ )
400
+ signal_process_group(record.pgid, signal_module.SIGKILL)
401
+ _wait_for_group_exit(record.pgid, timeout_s=1.0)
402
+ return ProcessReapOutcome(
403
+ pgid=record.pgid,
404
+ command=record.command,
405
+ origin=record.origin,
406
+ runtime_s=runtime_s,
407
+ signal_used=SIGNAL_KILL,
408
+ )
409
+
410
+
411
+ def reap_tracked_groups(
412
+ registry: ProcessGroupRegistry | None,
413
+ *,
414
+ grace_seconds: float = PROCESS_REAP_GRACE_SECONDS,
415
+ ) -> tuple[ProcessReapOutcome, ...]:
416
+ """Terminate every tracked group that is still alive. Never raises."""
417
+ if registry is None:
418
+ return ()
419
+ outcomes: list[ProcessReapOutcome] = []
420
+ for record in registry.live():
421
+ try:
422
+ outcome = reap_process_group(record, grace_seconds=grace_seconds)
423
+ except Exception: # noqa: BLE001 - teardown must never break finalization
424
+ outcome = ProcessReapOutcome(
425
+ pgid=record.pgid,
426
+ command=record.command,
427
+ origin=record.origin,
428
+ runtime_s=record.runtime_s(),
429
+ signal_used=SIGNAL_NONE,
430
+ )
431
+ registry.release(record.pgid)
432
+ outcomes.append(outcome)
433
+ return tuple(outcomes)
434
+
435
+
436
+ def survivor_payloads(registry: ProcessGroupRegistry | None) -> tuple[dict[str, Any], ...]:
437
+ """Payloads for tracked groups still alive, without touching them."""
438
+ if registry is None:
439
+ return ()
440
+ now = time.monotonic()
441
+ return tuple(record.payload(now=now) for record in registry.live())
442
+
443
+
444
+ # ---------------------------------------------------------------------------
445
+ # Spawning inside a tracked group
446
+ # ---------------------------------------------------------------------------
447
+
448
+
449
+ def new_process_group_popen_kwargs() -> dict[str, Any]:
450
+ """Popen kwargs that put the child in its own process group.
451
+
452
+ POSIX only. On Windows a new console process group would detach the child
453
+ from console Ctrl-C without buying anything back -- there is no ``killpg``,
454
+ so nothing could be reaped -- so the guard makes this a true no-op there and
455
+ foreground commands keep behaving exactly as they did.
456
+ """
457
+ if not process_groups_supported(): # pragma: no cover - exercised on Windows
458
+ return {}
459
+ return {"start_new_session": True}
460
+
461
+
462
+ def _describe_command(args: Any) -> str:
463
+ if isinstance(args, str):
464
+ return args
465
+ try:
466
+ return " ".join(str(item) for item in args)
467
+ except TypeError:
468
+ return str(args)
469
+
470
+
471
+ def run_in_tracked_process_group(
472
+ args: Any,
473
+ *,
474
+ shell: bool = False,
475
+ cwd: str | None = None,
476
+ env: dict[str, str] | None = None,
477
+ timeout: float | None = None,
478
+ registry: ProcessGroupRegistry | None = None,
479
+ origin: str = "shell_run",
480
+ command_label: str = "",
481
+ stdin: Any | None = None,
482
+ popen_factory: Callable[..., subprocess.Popen[str]] | None = None,
483
+ ) -> subprocess.CompletedProcess[str]:
484
+ """``subprocess.run``-equivalent that starts the command in its own group.
485
+
486
+ Semantics match ``subprocess.run(capture_output=True, text=True,
487
+ timeout=...)`` exactly, including raising :class:`subprocess.TimeoutExpired`
488
+ and killing the direct child on expiry. The only additions are that the
489
+ command leads its own process group and that the group is registered while
490
+ it is alive, so a later reap can terminate the children the timeout kill
491
+ orphans today.
492
+
493
+ Without a registry there is nothing to track, so there is no reason to hold a
494
+ handle: the call goes straight to ``subprocess.run``. That keeps every
495
+ non-tracking caller -- and the long-standing test seam of patching
496
+ ``subprocess.run`` -- behaving exactly as before.
497
+ """
498
+ popen_kwargs: dict[str, Any] = {
499
+ "stdout": subprocess.PIPE,
500
+ "stderr": subprocess.PIPE,
501
+ "text": True,
502
+ **new_process_group_popen_kwargs(),
503
+ }
504
+ # Left unset by default so interactive shell work keeps the caller's stdin.
505
+ # Automated callers pass ``subprocess.DEVNULL`` so a command that prompts
506
+ # reads EOF and fails immediately instead of waiting on a terminal nobody
507
+ # is watching.
508
+ if stdin is not None:
509
+ popen_kwargs["stdin"] = stdin
510
+ if cwd is not None:
511
+ popen_kwargs["cwd"] = cwd
512
+ if env is not None:
513
+ popen_kwargs["env"] = env
514
+ if registry is None:
515
+ return subprocess.run(
516
+ args,
517
+ shell=shell,
518
+ timeout=timeout,
519
+ check=False,
520
+ **popen_kwargs,
521
+ )
522
+ # Resolved at call time, not bound as a default: `subprocess.Popen` and
523
+ # `subprocess.run` are the seams callers and tests patch, and a default
524
+ # argument would capture the original before any patch could take effect.
525
+ factory = popen_factory if popen_factory is not None else subprocess.Popen
526
+ # The body mirrors CPython's subprocess.run: the Popen context manager closes
527
+ # the pipes and reaps the child on every exit path, and a timeout kills the
528
+ # direct child before re-raising. The only additions are the process group and
529
+ # its registration.
530
+ with factory(args, shell=shell, **popen_kwargs) as process:
531
+ pgid = resolve_process_group_id(int(getattr(process, "pid", 0) or 0))
532
+ tracked: TrackedProcessGroup | None = None
533
+ if pgid is not None and registry is not None:
534
+ label = str(command_label) if command_label else _describe_command(args)
535
+ tracked = registry.register(pgid=pgid, command=label, origin=origin)
536
+ try:
537
+ stdout, stderr = process.communicate(timeout=timeout)
538
+ except subprocess.TimeoutExpired as exc:
539
+ # Killing only the leader is what orphans the children today. That is
540
+ # left as-is on purpose: the group stays registered, and finalization
541
+ # reaps it under the single audited decision path.
542
+ process.kill()
543
+ if os.name == "nt": # pragma: no cover - exercised on Windows
544
+ exc.stdout, exc.stderr = process.communicate()
545
+ else:
546
+ process.wait()
547
+ raise
548
+ except BaseException:
549
+ # Ctrl-C lands here. Before this change the terminal's SIGINT reached
550
+ # the whole foreground group, so interrupting a command killed its
551
+ # children too; now that the command leads its own session the runner
552
+ # has to do that itself, or an interrupted test run would keep going.
553
+ process.kill()
554
+ if tracked is not None:
555
+ reap_process_group(tracked, grace_seconds=1.0)
556
+ raise
557
+ finally:
558
+ if tracked is not None and registry is not None:
559
+ registry.release_if_finished(tracked.pgid)
560
+ returncode = process.poll()
561
+ return subprocess.CompletedProcess(
562
+ args=args,
563
+ returncode=returncode if returncode is not None else 1,
564
+ stdout=stdout,
565
+ stderr=stderr,
566
+ )