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,1236 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import hashlib
5
+ import ipaddress
6
+ import json
7
+ import os
8
+ import platform
9
+ import secrets
10
+ import shutil
11
+ import signal
12
+ import socket
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ import uuid
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+ from enum import StrEnum
20
+ from pathlib import Path
21
+ from typing import Any
22
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
23
+
24
+ from .background_runner import _apply_env_overrides
25
+ from .branding import default_sandbox_docker_image
26
+ from .error_text import sanitize_optional_error_summary
27
+ from .sandbox_runner import (
28
+ _SENSITIVE_ENV_KEYS,
29
+ _build_bwrap_argv,
30
+ _build_docker_argv,
31
+ _docker_cleanup_container,
32
+ _supports_bwrap_unshare_cgroup,
33
+ )
34
+ from .sandbox_settings import ShellSandboxSettings
35
+
36
+ SERVICE_SCHEMA_VERSION = 1
37
+ SERVICE_STDOUT_LOG = "stdout.log"
38
+ SERVICE_STDERR_LOG = "stderr.log"
39
+ SERVICE_METADATA = "service.json"
40
+ SERVICE_PREVIEW_READY = "preview-ready.json"
41
+ SERVICE_PREVIEW_TOKEN = "preview-token"
42
+ _STOP_TIMEOUT_S = 3.0
43
+ _KILL_TIMEOUT_S = 1.0
44
+
45
+
46
+ class ProcessOwnership(StrEnum):
47
+ SESSION = "SESSION"
48
+ DURABLE_SERVICE = "DURABLE_SERVICE"
49
+
50
+
51
+ class DurableServiceStatus(StrEnum):
52
+ STARTING = "starting"
53
+ RUNNING = "running"
54
+ EXITED = "exited"
55
+ FAILED = "failed"
56
+ STOPPED = "stopped"
57
+ STALE = "stale"
58
+ UNKNOWN = "unknown"
59
+
60
+
61
+ class ReadinessStatus(StrEnum):
62
+ READY = "ready"
63
+ NOT_READY = "not_ready"
64
+ FAILED = "failed"
65
+ UNSUPPORTED = "unsupported"
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class DurableServiceStart:
70
+ service_id: str
71
+ payload: dict[str, Any]
72
+
73
+
74
+ class DurableServiceManager:
75
+ def __init__(
76
+ self,
77
+ *,
78
+ root: Path,
79
+ state_dir: Path,
80
+ settings: ShellSandboxSettings,
81
+ ) -> None:
82
+ self.root = root.resolve()
83
+ self.state_dir = state_dir.resolve()
84
+ self.settings = settings
85
+ self._popens: dict[str, subprocess.Popen[bytes]] = {}
86
+
87
+ def start(
88
+ self,
89
+ *,
90
+ cmd: str,
91
+ cwd: Path,
92
+ readiness: dict[str, Any] | None = None,
93
+ ) -> DurableServiceStart:
94
+ cleaned_cmd = str(cmd or "").strip()
95
+ if not cleaned_cmd:
96
+ raise ValueError("cmd cannot be empty")
97
+ cwd_abs = cwd.resolve()
98
+ try:
99
+ cwd_abs.relative_to(self.root)
100
+ except ValueError as exc:
101
+ raise ValueError(f"cwd escapes root: {cwd}") from exc
102
+
103
+ readiness_spec = normalize_readiness_spec(readiness)
104
+ return self._start_prepared(
105
+ cleaned_cmd=cleaned_cmd,
106
+ cwd_abs=cwd_abs,
107
+ readiness_spec=readiness_spec,
108
+ launch_builder=lambda service_id: self._build_launch(
109
+ cmd=cleaned_cmd,
110
+ cwd=cwd_abs,
111
+ service_id=service_id,
112
+ readiness=readiness_spec,
113
+ ),
114
+ )
115
+
116
+ def resolve_preview_access(self, requested: object = "auto") -> str:
117
+ return _resolve_preview_access(
118
+ requested,
119
+ configured=getattr(self.settings, "preview_access", "auto"),
120
+ )
121
+
122
+ def start_preview(
123
+ self,
124
+ *,
125
+ cwd: Path,
126
+ access: str = "auto",
127
+ port: int | None = None,
128
+ ) -> DurableServiceStart:
129
+ """Start a constrained static preview using semantic network access."""
130
+
131
+ cwd_abs = cwd.resolve()
132
+ try:
133
+ cwd_abs.relative_to(self.root)
134
+ except ValueError as exc:
135
+ raise ValueError(f"cwd escapes root: {cwd}") from exc
136
+ if not cwd_abs.is_dir():
137
+ raise ValueError(f"preview cwd is not a directory: {cwd}")
138
+ if port is not None and (
139
+ isinstance(port, bool) or not isinstance(port, int) or port <= 0 or port > 65535
140
+ ):
141
+ raise ValueError("preview port must be between 1 and 65535")
142
+ effective_access = self.resolve_preview_access(access)
143
+ token = secrets.token_urlsafe(32) if effective_access == "lan" else None
144
+ readiness_spec = normalize_readiness_spec(
145
+ {
146
+ "type": "preview_ready",
147
+ "timeout_s": 10.0,
148
+ "interval_s": 0.1,
149
+ }
150
+ )
151
+
152
+ def _preview_launch(service_id: str) -> dict[str, Any]:
153
+ service_dir = self._service_dir(service_id)
154
+ ready_path = service_dir / SERVICE_PREVIEW_READY
155
+ token_path = service_dir / SERVICE_PREVIEW_TOKEN
156
+ argv = [
157
+ sys.executable,
158
+ "-m",
159
+ "alysis_code.preview_server",
160
+ "--root",
161
+ os.fspath(cwd_abs),
162
+ "--access",
163
+ effective_access,
164
+ "--ready-file",
165
+ os.fspath(ready_path),
166
+ ]
167
+ if port is not None:
168
+ argv.extend(["--port", str(port)])
169
+ if token is not None:
170
+ _write_private_text(token_path, token)
171
+ argv.extend(["--token-file", os.fspath(token_path)])
172
+ return {
173
+ "backend": "host-preview",
174
+ "popen_args": argv,
175
+ "popen_cwd": self.root,
176
+ "shell": False,
177
+ "env": _safe_parent_env(),
178
+ }
179
+
180
+ return self._start_prepared(
181
+ cleaned_cmd=f"alysis-workspace-preview:{effective_access}",
182
+ cwd_abs=cwd_abs,
183
+ readiness_spec=readiness_spec,
184
+ launch_builder=_preview_launch,
185
+ metadata_extra={
186
+ "preview_access": effective_access,
187
+ "preview_authentication_required": token is not None,
188
+ },
189
+ metadata_from_readiness=_preview_metadata_from_readiness,
190
+ )
191
+
192
+ def _start_prepared(
193
+ self,
194
+ *,
195
+ cleaned_cmd: str,
196
+ cwd_abs: Path,
197
+ readiness_spec: dict[str, Any],
198
+ launch_builder: Callable[[str], dict[str, Any]],
199
+ metadata_extra: dict[str, Any] | None = None,
200
+ metadata_from_readiness: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
201
+ ) -> DurableServiceStart:
202
+ service_id = f"svc_{uuid.uuid4().hex[:16]}"
203
+ service_dir = self._service_dir(service_id)
204
+ service_dir.mkdir(parents=True, exist_ok=True)
205
+ stdout_path = service_dir / SERVICE_STDOUT_LOG
206
+ stderr_path = service_dir / SERVICE_STDERR_LOG
207
+ metadata_path = service_dir / SERVICE_METADATA
208
+ launch = launch_builder(service_id)
209
+
210
+ started_at = time.time()
211
+ try:
212
+ with (
213
+ stdout_path.open("ab", buffering=0) as stdout_fh,
214
+ stderr_path.open("ab", buffering=0) as stderr_fh,
215
+ ):
216
+ popen = subprocess.Popen(
217
+ launch["popen_args"],
218
+ shell=bool(launch["shell"]),
219
+ cwd=str(launch["popen_cwd"]),
220
+ stdin=subprocess.DEVNULL,
221
+ stdout=stdout_fh,
222
+ stderr=stderr_fh,
223
+ env=launch["env"],
224
+ start_new_session=(os.name != "nt"),
225
+ creationflags=(subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0),
226
+ close_fds=True,
227
+ )
228
+ except BaseException:
229
+ self._safe_unlink(metadata_path)
230
+ self._safe_unlink(service_dir / SERVICE_PREVIEW_READY)
231
+ self._safe_unlink(service_dir / SERVICE_PREVIEW_TOKEN)
232
+ raise
233
+
234
+ self._popens[service_id] = popen
235
+ metadata = {
236
+ "schema_version": SERVICE_SCHEMA_VERSION,
237
+ "service_id": service_id,
238
+ "ownership": ProcessOwnership.DURABLE_SERVICE.value,
239
+ "status": DurableServiceStatus.RUNNING.value,
240
+ "pid": popen.pid,
241
+ "pgid": _process_group_id(popen.pid),
242
+ "pid_start_token": _pid_start_token(popen.pid),
243
+ "started_at_wall": started_at,
244
+ "root": os.fspath(self.root),
245
+ "cwd": os.fspath(cwd_abs),
246
+ "backend": str(launch["backend"]),
247
+ "container_name": launch.get("container_name"),
248
+ "cmd_sha256": hashlib.sha256(cleaned_cmd.encode("utf-8")).hexdigest(),
249
+ "readiness": readiness_spec,
250
+ "stdout_log_path": os.fspath(stdout_path),
251
+ "stderr_log_path": os.fspath(stderr_path),
252
+ }
253
+ metadata.update(metadata_extra or {})
254
+ self._write_metadata(metadata)
255
+ readiness_payload = self._check_readiness(
256
+ metadata=metadata,
257
+ timeout_s=_readiness_timeout(readiness_spec),
258
+ )
259
+ if readiness_payload["status"] != ReadinessStatus.READY.value:
260
+ self._terminate_loaded_metadata(metadata, remove_metadata=False)
261
+ status_payload = self.status(service_id)
262
+ self._safe_unlink(service_dir / SERVICE_PREVIEW_TOKEN)
263
+ status_payload.pop("access_url", None)
264
+ status_payload["failure_category"] = "readiness_failed"
265
+ status_payload["readiness"] = readiness_payload
266
+ startup_error = _service_startup_error(stderr_path=stderr_path, stdout_path=stdout_path)
267
+ if startup_error:
268
+ status_payload["startup_error"] = startup_error
269
+ return DurableServiceStart(service_id=service_id, payload=status_payload)
270
+ if metadata_from_readiness is not None:
271
+ try:
272
+ metadata.update(metadata_from_readiness(readiness_payload))
273
+ self._write_metadata(metadata)
274
+ except BaseException:
275
+ self._terminate_loaded_metadata(metadata, remove_metadata=False)
276
+ self._safe_unlink(service_dir / SERVICE_PREVIEW_TOKEN)
277
+ raise
278
+ return DurableServiceStart(service_id=service_id, payload=self.status(service_id))
279
+
280
+ def status(self, service_id: str) -> dict[str, Any]:
281
+ metadata = self._read_metadata(service_id)
282
+ if metadata is None:
283
+ return {
284
+ "service_id": service_id,
285
+ "ownership": ProcessOwnership.DURABLE_SERVICE.value,
286
+ "status": DurableServiceStatus.UNKNOWN.value,
287
+ "alive": False,
288
+ "readiness": {
289
+ "type": "process_alive",
290
+ "status": ReadinessStatus.FAILED.value,
291
+ "detail": "metadata not found",
292
+ },
293
+ "failure_category": "unknown_service",
294
+ }
295
+ alive, identity_valid = self._metadata_process_alive(metadata)
296
+ status = (
297
+ DurableServiceStatus.RUNNING.value
298
+ if alive
299
+ else DurableServiceStatus.STALE.value
300
+ if not identity_valid
301
+ else DurableServiceStatus.EXITED.value
302
+ )
303
+ metadata["status"] = status
304
+ self._write_metadata(metadata)
305
+ readiness = self._check_readiness(
306
+ metadata=metadata,
307
+ timeout_s=_readiness_timeout(dict(metadata.get("readiness") or {})),
308
+ )
309
+ payload = _metadata_public_payload(
310
+ metadata,
311
+ status=status,
312
+ alive=alive,
313
+ identity_valid=identity_valid,
314
+ readiness=readiness,
315
+ )
316
+ return self._attach_preview_access_url(payload, metadata)
317
+
318
+ def stop(self, service_id: str) -> dict[str, Any]:
319
+ metadata = self._read_metadata(service_id)
320
+ if metadata is None:
321
+ return {
322
+ "service_id": service_id,
323
+ "ownership": ProcessOwnership.DURABLE_SERVICE.value,
324
+ "status": DurableServiceStatus.UNKNOWN.value,
325
+ "stopped": False,
326
+ "failure_category": "unknown_service",
327
+ }
328
+ alive, identity_valid = self._metadata_process_alive(metadata)
329
+ if alive and not identity_valid:
330
+ payload = _metadata_public_payload(
331
+ metadata,
332
+ status=DurableServiceStatus.STALE.value,
333
+ alive=False,
334
+ identity_valid=False,
335
+ readiness={
336
+ "type": "process_alive",
337
+ "status": ReadinessStatus.FAILED.value,
338
+ "detail": "pid identity does not match durable service metadata",
339
+ },
340
+ )
341
+ payload["stopped"] = False
342
+ payload["failure_category"] = "stale_metadata_identity_mismatch"
343
+ metadata["status"] = DurableServiceStatus.STALE.value
344
+ self._write_metadata(metadata)
345
+ return payload
346
+ if alive:
347
+ self._terminate_loaded_metadata(metadata, remove_metadata=True)
348
+ else:
349
+ self._remove_metadata(metadata)
350
+ payload = _metadata_public_payload(
351
+ metadata,
352
+ status=DurableServiceStatus.STOPPED.value,
353
+ alive=False,
354
+ identity_valid=identity_valid,
355
+ readiness={
356
+ "type": str(dict(metadata.get("readiness") or {}).get("type") or "process_alive"),
357
+ "status": ReadinessStatus.FAILED.value,
358
+ "detail": "service stopped",
359
+ },
360
+ )
361
+ payload["stopped"] = True
362
+ return payload
363
+
364
+ def list_active(self) -> list[dict[str, Any]]:
365
+ if not self.state_dir.exists():
366
+ return []
367
+ services: list[dict[str, Any]] = []
368
+ for metadata_path in sorted(self.state_dir.glob(f"*/{SERVICE_METADATA}")):
369
+ service_id = metadata_path.parent.name
370
+ payload = self.status(service_id)
371
+ if payload.get("status") == DurableServiceStatus.RUNNING.value:
372
+ services.append(payload)
373
+ return services
374
+
375
+ def _build_launch(
376
+ self,
377
+ *,
378
+ cmd: str,
379
+ cwd: Path,
380
+ service_id: str,
381
+ readiness: dict[str, Any] | None = None,
382
+ ) -> dict[str, Any]:
383
+ if self.settings.mode == "off":
384
+ return {
385
+ "backend": "host",
386
+ "popen_args": cmd,
387
+ "popen_cwd": cwd,
388
+ "shell": True,
389
+ "env": _safe_parent_env(),
390
+ }
391
+
392
+ backend = self.settings.backend
393
+ has_bwrap = platform.system().lower() == "linux" and shutil.which("bwrap") is not None
394
+ has_docker = shutil.which("docker") is not None
395
+ if backend in {"auto", "bwrap"} and has_bwrap:
396
+ argv, env = _build_bwrap_argv(
397
+ root=self.root,
398
+ cwd=cwd,
399
+ cmd=cmd,
400
+ network=self.settings.network,
401
+ clear_env=self.settings.clear_env,
402
+ profile=self.settings.bwrap_profile,
403
+ unshare_cgroup=_supports_bwrap_unshare_cgroup(),
404
+ )
405
+ argv = [item for item in argv if item != "--die-with-parent"]
406
+ return {
407
+ "backend": "bwrap",
408
+ "popen_args": argv,
409
+ "popen_cwd": self.root,
410
+ "shell": False,
411
+ "env": env,
412
+ }
413
+ if backend in {"auto", "docker"} and has_docker:
414
+ published_ports: tuple[tuple[str, int, int], ...] = ()
415
+ readiness_spec = readiness or {}
416
+ if str(readiness_spec.get("type") or "") == "tcp":
417
+ host = str(readiness_spec.get("host") or "localhost").strip()
418
+ port = int(readiness_spec.get("port") or 0)
419
+ if not _host_resolves_only_to_loopback(host):
420
+ raise RuntimeError(
421
+ "Docker durable-service TCP readiness must resolve to a local loopback "
422
+ "interface. Use the workspace preview access policy for broader exposure."
423
+ )
424
+ if self.settings.network == "off":
425
+ raise RuntimeError(
426
+ "Docker networking is disabled. Use workspace_preview_start for static "
427
+ "localhost previews, or explicitly enable shell sandbox networking for "
428
+ "a containerized development server."
429
+ )
430
+ published_ports = ((_system_loopback_address(), port, port),)
431
+ container_name = f"alysis-svc-{service_id[-12:]}"
432
+ argv, env = _build_docker_argv(
433
+ root=self.root,
434
+ cwd=cwd,
435
+ cmd=cmd,
436
+ container_name=container_name,
437
+ network=self.settings.network,
438
+ docker_image=self.settings.docker_image or default_sandbox_docker_image("dev"),
439
+ clear_env=self.settings.clear_env,
440
+ pids_limit=self.settings.docker_pids_limit,
441
+ memory_limit=self.settings.docker_memory,
442
+ cpus=self.settings.docker_cpus,
443
+ read_only_rootfs=self.settings.docker_read_only,
444
+ protect_repo_meta=self.settings.protect_repo_meta,
445
+ env_allowlist=self.settings.docker_env_allowlist,
446
+ published_ports=published_ports,
447
+ )
448
+ return {
449
+ "backend": "docker",
450
+ "container_name": container_name,
451
+ "popen_args": argv,
452
+ "popen_cwd": self.root,
453
+ "shell": False,
454
+ "env": env,
455
+ }
456
+ if self.settings.mode == "warn":
457
+ raise RuntimeError(
458
+ "Durable service sandbox unavailable; host fallback is disabled for durable services."
459
+ )
460
+ raise RuntimeError(
461
+ "Shell sandbox strict mode is enabled, but no usable durable service backend is available."
462
+ )
463
+
464
+ def _check_readiness(
465
+ self,
466
+ *,
467
+ metadata: dict[str, Any],
468
+ timeout_s: float,
469
+ ) -> dict[str, Any]:
470
+ readiness = dict(metadata.get("readiness") or {})
471
+ readiness_type = str(readiness.get("type") or "process_alive")
472
+ deadline = time.monotonic() + max(0.0, timeout_s)
473
+ interval_s = max(0.02, min(float(readiness.get("interval_s") or 0.1), 1.0))
474
+ last_detail = ""
475
+ while True:
476
+ alive, identity_valid = self._metadata_process_alive(metadata)
477
+ if readiness_type == "process_alive":
478
+ status = (
479
+ ReadinessStatus.READY if alive and identity_valid else ReadinessStatus.FAILED
480
+ )
481
+ return {
482
+ "type": readiness_type,
483
+ "status": status.value,
484
+ "detail": "process is alive"
485
+ if status == ReadinessStatus.READY
486
+ else "process is not alive",
487
+ }
488
+ if not alive or not identity_valid:
489
+ return {
490
+ "type": readiness_type,
491
+ "status": ReadinessStatus.FAILED.value,
492
+ "detail": "process is not alive",
493
+ }
494
+ if readiness_type == "tcp":
495
+ host = str(readiness.get("host") or "localhost")
496
+ port = int(readiness.get("port") or 0)
497
+ try:
498
+ with socket.create_connection((host, port), timeout=min(interval_s, 0.5)):
499
+ return {
500
+ "type": readiness_type,
501
+ "status": ReadinessStatus.READY.value,
502
+ "host": host,
503
+ "port": port,
504
+ "detail": "tcp connection succeeded",
505
+ }
506
+ except OSError as exc:
507
+ last_detail = str(exc)
508
+ elif readiness_type == "preview_ready":
509
+ service_id = str(metadata.get("service_id") or "")
510
+ ready_path = self._service_dir(service_id) / SERVICE_PREVIEW_READY
511
+ try:
512
+ ready = _read_preview_ready(
513
+ ready_path,
514
+ expected_access=str(metadata.get("preview_access") or ""),
515
+ )
516
+ except (OSError, ValueError) as exc:
517
+ last_detail = str(exc)
518
+ else:
519
+ host = str(ready["probe_host"])
520
+ port = int(ready["port"])
521
+ try:
522
+ with socket.create_connection((host, port), timeout=min(interval_s, 0.5)):
523
+ return {
524
+ "type": readiness_type,
525
+ "status": ReadinessStatus.READY.value,
526
+ "host": host,
527
+ "port": port,
528
+ "preview_urls": list(ready["preview_urls"]),
529
+ "access": str(ready["access"]),
530
+ "runtime": str(ready["runtime"]),
531
+ "authentication_required": bool(ready["authentication_required"]),
532
+ "detail": "preview server reported ready and accepted TCP",
533
+ }
534
+ except OSError as exc:
535
+ last_detail = str(exc)
536
+ elif readiness_type == "unix_socket":
537
+ path = Path(str(readiness.get("path") or ""))
538
+ if path.exists():
539
+ return {
540
+ "type": readiness_type,
541
+ "status": ReadinessStatus.READY.value,
542
+ "path": os.fspath(path),
543
+ "detail": "unix socket exists",
544
+ }
545
+ last_detail = "unix socket does not exist"
546
+ elif readiness_type == "command":
547
+ command = str(readiness.get("command") or "").strip()
548
+ if not command:
549
+ return {
550
+ "type": readiness_type,
551
+ "status": ReadinessStatus.FAILED.value,
552
+ "detail": "readiness command is empty",
553
+ }
554
+ try:
555
+ result = subprocess.run(
556
+ command,
557
+ shell=True,
558
+ cwd=str(metadata.get("cwd") or self.root),
559
+ env=_safe_parent_env(),
560
+ capture_output=True,
561
+ text=True,
562
+ timeout=min(max(0.1, timeout_s), 5.0),
563
+ check=False,
564
+ )
565
+ except subprocess.TimeoutExpired:
566
+ last_detail = "readiness command timed out"
567
+ else:
568
+ if result.returncode == 0:
569
+ return {
570
+ "type": readiness_type,
571
+ "status": ReadinessStatus.READY.value,
572
+ "exit_code": result.returncode,
573
+ "detail": "readiness command passed",
574
+ }
575
+ last_detail = f"readiness command exited {result.returncode}"
576
+ else:
577
+ return {
578
+ "type": readiness_type,
579
+ "status": ReadinessStatus.UNSUPPORTED.value,
580
+ "detail": f"unsupported readiness type: {readiness_type}",
581
+ }
582
+
583
+ if time.monotonic() >= deadline:
584
+ return {
585
+ "type": readiness_type,
586
+ "status": ReadinessStatus.FAILED.value,
587
+ "detail": last_detail or "readiness probe timed out",
588
+ }
589
+ time.sleep(interval_s)
590
+
591
+ def _metadata_process_alive(self, metadata: dict[str, Any]) -> tuple[bool, bool]:
592
+ pid = int(metadata.get("pid") or 0)
593
+ if pid <= 0:
594
+ return False, False
595
+ expected_token = str(metadata.get("pid_start_token") or "")
596
+ if expected_token:
597
+ current_token = _pid_start_token(pid)
598
+ if current_token and current_token != expected_token:
599
+ return True, False
600
+ popen = self._popens.get(str(metadata.get("service_id") or ""))
601
+ if popen is not None and popen.poll() is not None:
602
+ return False, True
603
+ return _pid_exists(pid), True
604
+
605
+ def _terminate_loaded_metadata(
606
+ self,
607
+ metadata: dict[str, Any],
608
+ *,
609
+ remove_metadata: bool,
610
+ ) -> None:
611
+ service_id = str(metadata.get("service_id") or "")
612
+ popen = self._popens.get(service_id)
613
+ if str(metadata.get("backend") or "") == "docker" and metadata.get("container_name"):
614
+ _docker_cleanup_container(
615
+ str(metadata["container_name"]),
616
+ cwd=os.fspath(self.root),
617
+ env=_safe_parent_env(),
618
+ warning_callback=None,
619
+ reason="durable service stop",
620
+ quiet=True,
621
+ )
622
+ if popen is not None:
623
+ if popen.poll() is None:
624
+ _terminate_popen_tree(popen, timeout_s=_STOP_TIMEOUT_S)
625
+ else:
626
+ pid = int(metadata.get("pid") or 0)
627
+ pgid = metadata.get("pgid")
628
+ _terminate_pid_or_group(pid=pid, pgid=pgid, timeout_s=_STOP_TIMEOUT_S)
629
+ metadata["status"] = DurableServiceStatus.STOPPED.value
630
+ if remove_metadata:
631
+ self._remove_metadata(metadata)
632
+ else:
633
+ self._write_metadata(metadata)
634
+
635
+ def _read_metadata(self, service_id: str) -> dict[str, Any] | None:
636
+ normalized = _normalize_service_id(service_id)
637
+ if not normalized:
638
+ return None
639
+ path = self._service_dir(normalized) / SERVICE_METADATA
640
+ try:
641
+ raw = json.loads(path.read_text(encoding="utf-8"))
642
+ except (OSError, json.JSONDecodeError):
643
+ return None
644
+ if not isinstance(raw, dict):
645
+ return None
646
+ if raw.get("service_id") != normalized:
647
+ return None
648
+ return raw
649
+
650
+ def _write_metadata(self, metadata: dict[str, Any]) -> None:
651
+ service_id = str(metadata.get("service_id") or "")
652
+ service_dir = self._service_dir(service_id)
653
+ service_dir.mkdir(parents=True, exist_ok=True)
654
+ path = service_dir / SERVICE_METADATA
655
+ path.write_text(
656
+ json.dumps(_sanitize_metadata(metadata), indent=2, sort_keys=True) + "\n",
657
+ encoding="utf-8",
658
+ )
659
+
660
+ def _remove_metadata(self, metadata: dict[str, Any]) -> None:
661
+ service_id = str(metadata.get("service_id") or "")
662
+ service_dir = self._service_dir(service_id)
663
+ self._safe_unlink(service_dir / SERVICE_METADATA)
664
+ self._safe_unlink(service_dir / SERVICE_PREVIEW_READY)
665
+ self._safe_unlink(service_dir / SERVICE_PREVIEW_TOKEN)
666
+
667
+ def _attach_preview_access_url(
668
+ self,
669
+ payload: dict[str, Any],
670
+ metadata: dict[str, Any],
671
+ ) -> dict[str, Any]:
672
+ preview_url = str(payload.get("preview_url") or "").strip()
673
+ if not preview_url:
674
+ return payload
675
+ if not bool(metadata.get("preview_authentication_required")):
676
+ payload["access_url"] = preview_url
677
+ return payload
678
+ service_id = str(metadata.get("service_id") or "")
679
+ try:
680
+ token = (
681
+ (self._service_dir(service_id) / SERVICE_PREVIEW_TOKEN)
682
+ .read_text(encoding="utf-8")
683
+ .strip()
684
+ )
685
+ except OSError:
686
+ return payload
687
+ if token:
688
+ payload["access_url"] = _url_with_preview_token(preview_url, token)
689
+ return payload
690
+
691
+ def _service_dir(self, service_id: str) -> Path:
692
+ normalized = _normalize_service_id(service_id)
693
+ if not normalized:
694
+ raise ValueError("invalid service_id")
695
+ return self.state_dir / normalized
696
+
697
+ @staticmethod
698
+ def _safe_unlink(path: Path) -> None:
699
+ try:
700
+ path.unlink()
701
+ except FileNotFoundError:
702
+ return
703
+
704
+
705
+ def normalize_readiness_spec(raw: dict[str, Any] | None) -> dict[str, Any]:
706
+ if not raw:
707
+ return {"type": "process_alive", "timeout_s": 1.0, "interval_s": 0.1}
708
+ if not isinstance(raw, dict):
709
+ return {"type": "process_alive", "timeout_s": 1.0, "interval_s": 0.1}
710
+ readiness_type = str(raw.get("type") or "process_alive").strip().lower()
711
+ out: dict[str, Any] = {
712
+ "type": readiness_type,
713
+ "timeout_s": _bounded_float(raw.get("timeout_s"), default=5.0, low=0.0, high=30.0),
714
+ "interval_s": _bounded_float(raw.get("interval_s"), default=0.1, low=0.02, high=2.0),
715
+ }
716
+ if readiness_type == "tcp":
717
+ out["host"] = str(raw.get("host") or "localhost")
718
+ out["port"] = int(raw.get("port") or 0)
719
+ elif readiness_type == "unix_socket":
720
+ out["path"] = str(raw.get("path") or "")
721
+ elif readiness_type == "command":
722
+ out["command"] = str(raw.get("command") or "")
723
+ return out
724
+
725
+
726
+ def _resolve_preview_access(requested: object, *, configured: object) -> str:
727
+ requested_value = str(requested or "auto").strip().lower()
728
+ configured_value = str(configured or "auto").strip().lower()
729
+ allowed = {"auto", "local", "lan"}
730
+ if requested_value not in allowed:
731
+ raise ValueError(f"preview access must be one of: {', '.join(sorted(allowed))}")
732
+ if configured_value not in allowed:
733
+ configured_value = "auto"
734
+ effective = configured_value if requested_value == "auto" else requested_value
735
+ # Auto is deliberately semantic. The current runtime policy selects a local
736
+ # preview, while keeping the tool contract stable for future remote runtimes.
737
+ return "local" if effective == "auto" else effective
738
+
739
+
740
+ def _host_resolves_only_to_loopback(host: str) -> bool:
741
+ cleaned = str(host or "").strip()
742
+ if not cleaned:
743
+ return False
744
+ try:
745
+ addresses = socket.getaddrinfo(
746
+ cleaned,
747
+ None,
748
+ family=socket.AF_UNSPEC,
749
+ type=socket.SOCK_STREAM,
750
+ )
751
+ except OSError:
752
+ return False
753
+ resolved: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
754
+ for candidate in addresses:
755
+ raw_address = str(candidate[4][0]).split("%", 1)[0]
756
+ try:
757
+ resolved.append(ipaddress.ip_address(raw_address))
758
+ except ValueError:
759
+ return False
760
+ return bool(resolved) and all(address.is_loopback for address in resolved)
761
+
762
+
763
+ def _system_loopback_address() -> str:
764
+ candidates = socket.getaddrinfo(
765
+ "localhost",
766
+ None,
767
+ family=socket.AF_UNSPEC,
768
+ type=socket.SOCK_STREAM,
769
+ )
770
+ # Docker Desktop and WSL consistently support IPv4 host publication even
771
+ # when their IPv6 bridge is disabled. We still use the address returned by
772
+ # the operating system rather than embedding one in the service policy.
773
+ candidates.sort(key=lambda candidate: candidate[0] != socket.AF_INET)
774
+ for candidate in candidates:
775
+ raw_address = str(candidate[4][0]).split("%", 1)[0]
776
+ try:
777
+ address = ipaddress.ip_address(raw_address)
778
+ except ValueError:
779
+ continue
780
+ if address.is_loopback:
781
+ return address.compressed
782
+ raise RuntimeError("The operating system did not provide a local loopback address")
783
+
784
+
785
+ def _preview_metadata_from_readiness(readiness: dict[str, Any]) -> dict[str, Any]:
786
+ urls = [str(item) for item in readiness.get("preview_urls") or [] if str(item).strip()]
787
+ if not urls:
788
+ raise ValueError("preview readiness did not provide a reachable URL")
789
+ return {
790
+ "preview_url": urls[0],
791
+ "preview_urls": urls,
792
+ "preview_port": int(readiness.get("port") or 0),
793
+ "preview_runtime": str(readiness.get("runtime") or "unknown"),
794
+ }
795
+
796
+
797
+ def _read_preview_ready(path: Path, *, expected_access: str) -> dict[str, Any]:
798
+ try:
799
+ raw = json.loads(path.read_text(encoding="utf-8"))
800
+ except FileNotFoundError as exc:
801
+ raise OSError("preview readiness file has not been created") from exc
802
+ except json.JSONDecodeError as exc:
803
+ raise ValueError("preview readiness file is invalid JSON") from exc
804
+ if not isinstance(raw, dict):
805
+ raise ValueError("preview readiness payload must be an object")
806
+ access = str(raw.get("access") or "").strip().lower()
807
+ if access not in {"local", "lan"} or access != expected_access:
808
+ raise ValueError("preview readiness access does not match the requested policy")
809
+ try:
810
+ port = int(raw.get("port") or 0)
811
+ except (TypeError, ValueError) as exc:
812
+ raise ValueError("preview readiness port is invalid") from exc
813
+ if port <= 0 or port > 65535:
814
+ raise ValueError("preview readiness port is out of range")
815
+ probe_host = str(raw.get("probe_host") or "").strip()
816
+ if not probe_host or not _host_resolves_only_to_loopback(probe_host):
817
+ raise ValueError("preview readiness probe host must resolve only to loopback")
818
+ authentication_required = bool(raw.get("authentication_required"))
819
+ if authentication_required != (access == "lan"):
820
+ raise ValueError("preview readiness authentication does not match the access policy")
821
+ urls: list[str] = []
822
+ for candidate in raw.get("preview_urls") or []:
823
+ value = str(candidate or "").strip()
824
+ parsed = urlsplit(value)
825
+ try:
826
+ parsed_port = parsed.port
827
+ except ValueError:
828
+ continue
829
+ if parsed.scheme != "http" or not parsed.hostname or parsed_port != port:
830
+ continue
831
+ urls.append(value)
832
+ if not urls:
833
+ raise ValueError("preview readiness URLs are invalid")
834
+ return {
835
+ "access": access,
836
+ "port": port,
837
+ "probe_host": probe_host,
838
+ "preview_urls": list(dict.fromkeys(urls)),
839
+ "runtime": str(raw.get("runtime") or "unknown"),
840
+ "authentication_required": authentication_required,
841
+ }
842
+
843
+
844
+ def _write_private_text(path: Path, value: str) -> None:
845
+ path.parent.mkdir(parents=True, exist_ok=True)
846
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
847
+ try:
848
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
849
+ handle.write(value)
850
+ handle.write("\n")
851
+ except BaseException:
852
+ try:
853
+ path.unlink()
854
+ except FileNotFoundError:
855
+ pass
856
+ raise
857
+
858
+
859
+ def _url_with_preview_token(url: str, token: str) -> str:
860
+ parsed = urlsplit(url)
861
+ query = parse_qsl(parsed.query, keep_blank_values=True)
862
+ query = [(key, value) for key, value in query if key != "alysis_token"]
863
+ query.append(("alysis_token", token))
864
+ return urlunsplit(
865
+ (parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment)
866
+ )
867
+
868
+
869
+ def _readiness_timeout(readiness: dict[str, Any]) -> float:
870
+ return _bounded_float(readiness.get("timeout_s"), default=5.0, low=0.0, high=30.0)
871
+
872
+
873
+ def _bounded_float(value: Any, *, default: float, low: float, high: float) -> float:
874
+ try:
875
+ number = float(value)
876
+ except (TypeError, ValueError):
877
+ number = default
878
+ return max(low, min(high, number))
879
+
880
+
881
+ def _safe_parent_env() -> dict[str, str]:
882
+ blocked = {key.upper() for key in _SENSITIVE_ENV_KEYS}
883
+ return _apply_env_overrides(
884
+ {key: value for key, value in os.environ.items() if key.upper() not in blocked},
885
+ None,
886
+ )
887
+
888
+
889
+ def _service_startup_error(*, stderr_path: Path, stdout_path: Path) -> str | None:
890
+ for path in (stderr_path, stdout_path):
891
+ try:
892
+ raw = path.read_bytes()[-16_384:]
893
+ except OSError:
894
+ continue
895
+ text = raw.decode("utf-8", errors="replace").strip()
896
+ summary = sanitize_optional_error_summary(text, max_chars=1000)
897
+ if summary:
898
+ return summary
899
+ return None
900
+
901
+
902
+ def _normalize_service_id(service_id: str) -> str:
903
+ cleaned = str(service_id or "").strip()
904
+ if not cleaned or any(ch not in "abcdefghijklmnopqrstuvwxyz0123456789_" for ch in cleaned):
905
+ return ""
906
+ return cleaned
907
+
908
+
909
+ def _metadata_public_payload(
910
+ metadata: dict[str, Any],
911
+ *,
912
+ status: str,
913
+ alive: bool,
914
+ identity_valid: bool,
915
+ readiness: dict[str, Any],
916
+ ) -> dict[str, Any]:
917
+ payload = {
918
+ "service_id": metadata.get("service_id"),
919
+ "ownership": metadata.get("ownership") or ProcessOwnership.DURABLE_SERVICE.value,
920
+ "status": status,
921
+ "alive": bool(alive),
922
+ "identity_valid": bool(identity_valid),
923
+ "pid": metadata.get("pid"),
924
+ "pgid": metadata.get("pgid"),
925
+ "backend": metadata.get("backend"),
926
+ "readiness": readiness,
927
+ "start_timestamp": metadata.get("started_at_wall"),
928
+ "stdout_log_path": metadata.get("stdout_log_path"),
929
+ "stderr_log_path": metadata.get("stderr_log_path"),
930
+ "log_paths": {
931
+ "stdout": metadata.get("stdout_log_path"),
932
+ "stderr": metadata.get("stderr_log_path"),
933
+ },
934
+ "failure_category": None
935
+ if status == DurableServiceStatus.RUNNING.value
936
+ and readiness.get("status") == ReadinessStatus.READY.value
937
+ else "service_not_ready",
938
+ }
939
+ preview_url = str(metadata.get("preview_url") or "").strip()
940
+ if preview_url:
941
+ payload["preview_url"] = preview_url
942
+ preview_urls = [str(item) for item in metadata.get("preview_urls") or [] if str(item).strip()]
943
+ if preview_urls:
944
+ payload["preview_urls"] = preview_urls
945
+ preview_access = str(metadata.get("preview_access") or "").strip()
946
+ if preview_access:
947
+ payload["preview_access"] = preview_access
948
+ payload["authentication_required"] = bool(metadata.get("preview_authentication_required"))
949
+ payload["preview_runtime"] = str(metadata.get("preview_runtime") or "unknown")
950
+ payload["preview_port"] = int(metadata.get("preview_port") or 0)
951
+ return payload
952
+
953
+
954
+ def _sanitize_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
955
+ allowed_keys = {
956
+ "schema_version",
957
+ "service_id",
958
+ "ownership",
959
+ "status",
960
+ "pid",
961
+ "pgid",
962
+ "pid_start_token",
963
+ "started_at_wall",
964
+ "root",
965
+ "cwd",
966
+ "backend",
967
+ "container_name",
968
+ "cmd_sha256",
969
+ "readiness",
970
+ "stdout_log_path",
971
+ "stderr_log_path",
972
+ "preview_url",
973
+ "preview_urls",
974
+ "preview_access",
975
+ "preview_authentication_required",
976
+ "preview_port",
977
+ "preview_runtime",
978
+ }
979
+ return {key: metadata[key] for key in sorted(allowed_keys) if key in metadata}
980
+
981
+
982
+ def _pid_exists(pid: int) -> bool:
983
+ if pid <= 0:
984
+ return False
985
+ if os.name == "nt": # pragma: no cover - exercised on Windows
986
+ # ``os.kill(pid, 0)`` is not a non-mutating existence probe on Windows:
987
+ # CPython implements unsupported signals through TerminateProcess, so it
988
+ # can kill the service while it is still starting. Query its exit status
989
+ # through a read-only process handle instead.
990
+ import ctypes
991
+ from ctypes import wintypes
992
+
993
+ process_query_limited_information = 0x1000
994
+ still_active = 259
995
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
996
+ open_process = kernel32.OpenProcess
997
+ open_process.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
998
+ open_process.restype = wintypes.HANDLE
999
+ get_exit_code_process = kernel32.GetExitCodeProcess
1000
+ get_exit_code_process.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
1001
+ get_exit_code_process.restype = wintypes.BOOL
1002
+ close_handle = kernel32.CloseHandle
1003
+ close_handle.argtypes = [wintypes.HANDLE]
1004
+ close_handle.restype = wintypes.BOOL
1005
+
1006
+ handle = open_process(process_query_limited_information, False, pid)
1007
+ if not handle:
1008
+ return False
1009
+ try:
1010
+ exit_code = wintypes.DWORD()
1011
+ if not get_exit_code_process(handle, ctypes.byref(exit_code)):
1012
+ return False
1013
+ return int(exit_code.value) == still_active
1014
+ finally:
1015
+ close_handle(handle)
1016
+ try:
1017
+ os.kill(pid, 0)
1018
+ except OSError as exc:
1019
+ return exc.errno == errno.EPERM
1020
+ return True
1021
+
1022
+
1023
+ def _pid_start_token(pid: int) -> str | None:
1024
+ stat_path = Path("/proc") / str(pid) / "stat"
1025
+ try:
1026
+ text = stat_path.read_text(encoding="utf-8")
1027
+ except OSError:
1028
+ return None
1029
+ try:
1030
+ after_comm = text.rsplit(") ", 1)[1]
1031
+ fields = after_comm.split()
1032
+ return f"linux:{fields[19]}"
1033
+ except (IndexError, ValueError):
1034
+ return None
1035
+
1036
+
1037
+ def _process_group_id(pid: int) -> int | None:
1038
+ if os.name == "nt":
1039
+ return None
1040
+ try:
1041
+ return os.getpgid(pid)
1042
+ except OSError:
1043
+ return None
1044
+
1045
+
1046
+ def _terminate_popen_tree(popen: subprocess.Popen[bytes], *, timeout_s: float) -> None:
1047
+ if popen.poll() is not None:
1048
+ return
1049
+ if os.name == "nt": # pragma: no cover - exercised on Windows
1050
+ _terminate_windows_process_tree(pid=popen.pid, timeout_s=timeout_s)
1051
+ else:
1052
+ pgid = _process_group_id(popen.pid)
1053
+ if pgid is not None:
1054
+ with _ignore_process_lookup():
1055
+ os.killpg(pgid, signal.SIGTERM)
1056
+ else:
1057
+ with _ignore_process_lookup():
1058
+ popen.terminate()
1059
+ try:
1060
+ popen.wait(timeout=timeout_s)
1061
+ return
1062
+ except subprocess.TimeoutExpired:
1063
+ pass
1064
+ if os.name == "nt": # pragma: no cover - exercised on Windows
1065
+ _terminate_windows_process_tree(pid=popen.pid, timeout_s=_KILL_TIMEOUT_S)
1066
+ else:
1067
+ pgid = _process_group_id(popen.pid)
1068
+ if pgid is not None:
1069
+ with _ignore_process_lookup():
1070
+ os.killpg(pgid, signal.SIGKILL)
1071
+ else:
1072
+ with _ignore_process_lookup():
1073
+ popen.kill()
1074
+ with _ignore_timeout():
1075
+ popen.wait(timeout=_KILL_TIMEOUT_S)
1076
+
1077
+
1078
+ def _terminate_pid_or_group(*, pid: int, pgid: Any, timeout_s: float) -> None:
1079
+ if pid <= 0:
1080
+ return
1081
+ if os.name == "nt": # pragma: no cover - exercised on Windows
1082
+ _terminate_windows_process_tree(pid=pid, timeout_s=timeout_s)
1083
+ return
1084
+ _signal_pid_or_group(pid=pid, pgid=pgid, sig=signal.SIGTERM)
1085
+ deadline = time.monotonic() + timeout_s
1086
+ while time.monotonic() < deadline:
1087
+ if not _pid_exists(pid):
1088
+ return
1089
+ if _pid_is_zombie(pid):
1090
+ _signal_pid_or_group(pid=pid, pgid=pgid, sig=signal.SIGKILL)
1091
+ return
1092
+ time.sleep(0.05)
1093
+ _signal_pid_or_group(pid=pid, pgid=pgid, sig=signal.SIGKILL)
1094
+
1095
+
1096
+ def _terminate_windows_process_tree(*, pid: int, timeout_s: float) -> None:
1097
+ if pid <= 0:
1098
+ return
1099
+ _ = timeout_s
1100
+ import ctypes
1101
+ from ctypes import wintypes
1102
+
1103
+ th32cs_snapprocess = 0x00000002
1104
+ process_terminate = 0x0001
1105
+ invalid_handle_value = ctypes.c_void_p(-1).value
1106
+
1107
+ class ProcessEntry32W(ctypes.Structure):
1108
+ _fields_ = [
1109
+ ("dwSize", wintypes.DWORD),
1110
+ ("cntUsage", wintypes.DWORD),
1111
+ ("th32ProcessID", wintypes.DWORD),
1112
+ ("th32DefaultHeapID", ctypes.c_size_t),
1113
+ ("th32ModuleID", wintypes.DWORD),
1114
+ ("cntThreads", wintypes.DWORD),
1115
+ ("th32ParentProcessID", wintypes.DWORD),
1116
+ ("pcPriClassBase", wintypes.LONG),
1117
+ ("dwFlags", wintypes.DWORD),
1118
+ ("szExeFile", wintypes.WCHAR * 260),
1119
+ ]
1120
+
1121
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
1122
+ create_snapshot = kernel32.CreateToolhelp32Snapshot
1123
+ create_snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
1124
+ create_snapshot.restype = wintypes.HANDLE
1125
+ process_first = kernel32.Process32FirstW
1126
+ process_first.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry32W)]
1127
+ process_first.restype = wintypes.BOOL
1128
+ process_next = kernel32.Process32NextW
1129
+ process_next.argtypes = [wintypes.HANDLE, ctypes.POINTER(ProcessEntry32W)]
1130
+ process_next.restype = wintypes.BOOL
1131
+ open_process = kernel32.OpenProcess
1132
+ open_process.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
1133
+ open_process.restype = wintypes.HANDLE
1134
+ terminate_process = kernel32.TerminateProcess
1135
+ terminate_process.argtypes = [wintypes.HANDLE, wintypes.UINT]
1136
+ terminate_process.restype = wintypes.BOOL
1137
+ close_handle = kernel32.CloseHandle
1138
+ close_handle.argtypes = [wintypes.HANDLE]
1139
+ close_handle.restype = wintypes.BOOL
1140
+
1141
+ children_by_parent: dict[int, list[int]] = {}
1142
+ snapshot = create_snapshot(th32cs_snapprocess, 0)
1143
+ if snapshot and snapshot != invalid_handle_value:
1144
+ try:
1145
+ entry = ProcessEntry32W()
1146
+ entry.dwSize = ctypes.sizeof(entry)
1147
+ if process_first(snapshot, ctypes.byref(entry)):
1148
+ while True:
1149
+ child_pid = int(entry.th32ProcessID)
1150
+ parent_pid = int(entry.th32ParentProcessID)
1151
+ children_by_parent.setdefault(parent_pid, []).append(child_pid)
1152
+ if not process_next(snapshot, ctypes.byref(entry)):
1153
+ break
1154
+ finally:
1155
+ close_handle(snapshot)
1156
+
1157
+ ordered: list[int] = []
1158
+ pending = [pid]
1159
+ while pending:
1160
+ current = pending.pop()
1161
+ ordered.append(current)
1162
+ pending.extend(children_by_parent.get(current, ()))
1163
+
1164
+ for target_pid in reversed(ordered):
1165
+ handle = open_process(process_terminate, False, target_pid)
1166
+ if not handle:
1167
+ continue
1168
+ try:
1169
+ terminate_process(handle, 1)
1170
+ finally:
1171
+ close_handle(handle)
1172
+
1173
+
1174
+ def _signal_pid_or_group(*, pid: int, pgid: Any, sig: int) -> None:
1175
+ if os.name != "nt" and isinstance(pgid, int) and pgid > 0:
1176
+ try:
1177
+ os.killpg(pgid, sig)
1178
+ return
1179
+ except ProcessLookupError:
1180
+ return
1181
+ except PermissionError:
1182
+ # On macOS, an exited-but-unreaped process may keep its PID while its
1183
+ # process group is no longer signalable. Fall back to the leader PID;
1184
+ # a genuine permission failure still propagates from this call.
1185
+ pass
1186
+ with _ignore_process_lookup():
1187
+ os.kill(pid, sig)
1188
+
1189
+
1190
+ def _pid_is_zombie(pid: int) -> bool:
1191
+ if os.name == "nt" or pid <= 0:
1192
+ return False
1193
+ stat_path = Path("/proc") / str(pid) / "stat"
1194
+ try:
1195
+ text = stat_path.read_text(encoding="utf-8")
1196
+ except OSError:
1197
+ text = ""
1198
+ if text:
1199
+ try:
1200
+ state = text.rsplit(") ", 1)[1].split(maxsplit=1)[0]
1201
+ except (IndexError, ValueError):
1202
+ state = ""
1203
+ if state:
1204
+ return state == "Z"
1205
+
1206
+ ps = shutil.which("ps")
1207
+ if not ps:
1208
+ return False
1209
+ try:
1210
+ completed = subprocess.run(
1211
+ [ps, "-o", "stat=", "-p", str(pid)],
1212
+ check=False,
1213
+ capture_output=True,
1214
+ text=True,
1215
+ timeout=1.0,
1216
+ )
1217
+ except (OSError, subprocess.SubprocessError):
1218
+ return False
1219
+ state = completed.stdout.strip().split(maxsplit=1)[0] if completed.stdout.strip() else ""
1220
+ return state.startswith("Z")
1221
+
1222
+
1223
+ class _ignore_process_lookup:
1224
+ def __enter__(self) -> None:
1225
+ return None
1226
+
1227
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
1228
+ return isinstance(exc, ProcessLookupError)
1229
+
1230
+
1231
+ class _ignore_timeout:
1232
+ def __enter__(self) -> None:
1233
+ return None
1234
+
1235
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
1236
+ return isinstance(exc, subprocess.TimeoutExpired)