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,1267 @@
1
+ from __future__ import annotations
2
+
3
+ import ctypes
4
+ import os
5
+ import queue
6
+ import select
7
+ import subprocess
8
+ import threading
9
+ import time
10
+ import weakref
11
+ from collections import deque
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .errors import McpProcessError, McpProtocolError, McpTimeoutError
18
+ from .jsonrpc import (
19
+ JsonRpcIdGenerator,
20
+ JsonRpcNotification,
21
+ JsonRpcProtocolError,
22
+ JsonRpcRequest,
23
+ JsonRpcResponse,
24
+ build_jsonrpc_error_response,
25
+ build_jsonrpc_notification,
26
+ build_jsonrpc_request,
27
+ build_jsonrpc_result_response,
28
+ encode_jsonrpc_message,
29
+ parse_jsonrpc_line,
30
+ )
31
+ from .models import ResolvedMcpServer
32
+ from .server_requests import (
33
+ McpServerRequestContext,
34
+ McpServerRequestHandler,
35
+ McpServerRequestHandlerError,
36
+ McpUnsupportedServerRequestError,
37
+ )
38
+
39
+ _STDERR_LINE_LIMIT = 80
40
+ _STDERR_CHAR_LIMIT = 4000
41
+ _SHUTDOWN_WAIT_S = 0.75
42
+ _TERMINATE_WAIT_S = 1.0
43
+ _UNSUPPORTED_CLIENT_REQUEST_CODE = -32601
44
+ _STDOUT_READ_CHUNK_SIZE = 4096
45
+ _STDERR_READ_CHUNK_SIZE = 4096
46
+ _STDOUT_NOTIFICATION_QUIESCENCE_WAIT_S = 0.25
47
+ _STDOUT_RESPONSE_QUIESCENCE_WAIT_S = 0.1
48
+ _STDOUT_RESPONSE_FOLLOW_UP_BUDGET_S = _STDOUT_RESPONSE_QUIESCENCE_WAIT_S * 2
49
+ _STDERR_QUIESCENCE_WAIT_S = 0.75
50
+ _FD_READY_POLL_INTERVAL_S = 0.01
51
+ _READER_THREAD_JOIN_WAIT_S = 1.0
52
+ _RETIRED_REQUEST_STATE_RETENTION_LIMIT = 1024
53
+ _REQUEST_STATE_AWAITING_RESPONSE = "awaiting_response"
54
+ _REQUEST_STATE_AWAITING_FOLLOW_UP = "awaiting_follow_up"
55
+ _REQUEST_STATE_COMPLETED = "completed"
56
+ _REQUEST_STATE_RESPONSE_TIMED_OUT = "response_timed_out"
57
+ _REQUEST_STATE_FOLLOW_UP_TIMED_OUT = "follow_up_timed_out"
58
+ _REQUEST_STATE_CANCELLED = "cancelled"
59
+ _REQUEST_STATES_DISCARD_LATE_RESPONSES = frozenset(
60
+ {
61
+ _REQUEST_STATE_RESPONSE_TIMED_OUT,
62
+ _REQUEST_STATE_FOLLOW_UP_TIMED_OUT,
63
+ _REQUEST_STATE_CANCELLED,
64
+ }
65
+ )
66
+ _CONSERVATIVE_ENV_KEYS = (
67
+ "PATH",
68
+ "HOME",
69
+ "USERPROFILE",
70
+ "HOMEDRIVE",
71
+ "HOMEPATH",
72
+ "TMPDIR",
73
+ "TEMP",
74
+ "TMP",
75
+ "LANG",
76
+ "LC_ALL",
77
+ "TERM",
78
+ "SystemRoot",
79
+ "SYSTEMROOT",
80
+ "ComSpec",
81
+ "COMSPEC",
82
+ "PATHEXT",
83
+ "WINDIR",
84
+ )
85
+ _INTERNAL_CLIENT_REQUEST_CODE = -32603
86
+ _INVALID_STDOUT_UTF8_MESSAGE = "received invalid UTF-8 on stdio stdout"
87
+ _LIVE_STDIO_TRANSPORTS: weakref.WeakSet[Any] = weakref.WeakSet()
88
+
89
+
90
+ class McpStdioTransportError(McpProcessError):
91
+ pass
92
+
93
+
94
+ class McpStdioTransportTimeoutError(McpStdioTransportError, McpTimeoutError):
95
+ error_code = "mcp_stdio_transport_timeout"
96
+
97
+
98
+ class McpStdioTransportProtocolError(McpStdioTransportError, McpProtocolError):
99
+ error_code = "mcp_stdio_transport_protocol_error"
100
+ retryable = False
101
+
102
+
103
+ def _decode_stdout_protocol_line(line_bytes: bytes) -> str:
104
+ try:
105
+ return line_bytes.decode("utf-8").rstrip("\r")
106
+ except UnicodeDecodeError as exc:
107
+ raise McpStdioTransportProtocolError(_INVALID_STDOUT_UTF8_MESSAGE) from exc
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class _QueuedResponse:
112
+ response: JsonRpcResponse
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class _DeferredResponse:
117
+ response: JsonRpcResponse
118
+ follow_up_deadline_monotonic: float
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class _ObservedResponse:
123
+ follow_up_deadline_monotonic: float
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class _DeferredResponseFollowUpTimeout:
128
+ pass
129
+
130
+
131
+ @dataclass
132
+ class _PendingRequest:
133
+ response_queue: queue.Queue[Any]
134
+ follow_up_budget_s: float
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class _FatalTransportState:
139
+ message: str
140
+ exc_type: type[McpStdioTransportError]
141
+ stderr_append_sequence: int
142
+ stderr_read_sequence: int
143
+ stderr_last_activity_monotonic: float
144
+ fatal_monotonic: float
145
+
146
+
147
+ def build_stdio_subprocess_env(
148
+ *,
149
+ overlay_env: Mapping[str, str],
150
+ host_env: Mapping[str, str] | None = None,
151
+ ) -> dict[str, str]:
152
+ source_env = os.environ if host_env is None else host_env
153
+ env: dict[str, str] = {}
154
+ for key in _CONSERVATIVE_ENV_KEYS:
155
+ value = source_env.get(key)
156
+ if value:
157
+ env[key] = value
158
+ env["PYTHONIOENCODING"] = "utf-8"
159
+ env["PYTHONUNBUFFERED"] = "1"
160
+ for key, value in overlay_env.items():
161
+ env[str(key)] = str(value)
162
+ return env
163
+
164
+
165
+ class McpStdioTransport:
166
+ def __init__(
167
+ self,
168
+ *,
169
+ server: ResolvedMcpServer,
170
+ workspace_root: Path,
171
+ host_env: Mapping[str, str] | None = None,
172
+ server_request_context: McpServerRequestContext | None = None,
173
+ server_request_handler: McpServerRequestHandler | None = None,
174
+ ) -> None:
175
+ if server.transport != "stdio":
176
+ raise McpStdioTransportError(f"MCP server '{server.id}' is not a stdio transport.")
177
+ if server.command is None:
178
+ raise McpStdioTransportError(f"MCP server '{server.id}' is missing a stdio command.")
179
+ self.server = server
180
+ self.workspace_root = workspace_root.resolve()
181
+ self.host_env = os.environ if host_env is None else host_env
182
+ self._server_request_context = server_request_context
183
+ self._server_request_handler = server_request_handler
184
+ self.spawn_env = build_stdio_subprocess_env(
185
+ overlay_env=server.env,
186
+ host_env=self.host_env,
187
+ )
188
+ self._id_generator = JsonRpcIdGenerator()
189
+ self._notifications: queue.Queue[JsonRpcNotification] = queue.Queue()
190
+ self._pending: dict[int | str, _PendingRequest] = {}
191
+ self._pending_lock = threading.Lock()
192
+ self._request_states: dict[int | str, str] = {}
193
+ self._retired_request_state_ids: deque[int | str] = deque()
194
+ self._stderr_tail: deque[str] = deque(maxlen=_STDERR_LINE_LIMIT)
195
+ self._stderr_state_condition = threading.Condition()
196
+ self._stderr_append_sequence = 0
197
+ self._stderr_read_sequence = 0
198
+ self._stderr_quiescence_generation = 0
199
+ self._stderr_last_activity_monotonic = time.monotonic()
200
+ self._stderr_reader_idle = False
201
+ self._stderr_reader_closed = False
202
+ self._stdout_state_condition = threading.Condition()
203
+ self._stdout_quiescence_generation = 0
204
+ self._stdout_read_sequence = 0
205
+ self._stdout_last_activity_monotonic = time.monotonic()
206
+ self._stdout_reader_idle = False
207
+ self._stdout_reader_closed = False
208
+ self._stdout_thread: threading.Thread | None = None
209
+ self._stderr_thread: threading.Thread | None = None
210
+ self._process: subprocess.Popen[bytes] | None = None
211
+ self._write_lock = threading.Lock()
212
+ self._started = False
213
+ self._closed = False
214
+ self._closing = False
215
+ self._fatal_state: _FatalTransportState | None = None
216
+
217
+ @property
218
+ def process(self) -> subprocess.Popen[bytes] | None:
219
+ return self._process
220
+
221
+ @property
222
+ def closed(self) -> bool:
223
+ return self._closed
224
+
225
+ def diagnostic_snapshot(self) -> dict[str, Any]:
226
+ process = self._process
227
+ return {
228
+ "transport_id": id(self),
229
+ "server_id": self.server.id,
230
+ "command": self.server.command,
231
+ "pid": process.pid if process is not None else None,
232
+ "returncode": process.poll() if process is not None else None,
233
+ "started": self._started,
234
+ "closed": self._closed,
235
+ "closing": self._closing,
236
+ "stdout_thread_alive": bool(
237
+ self._stdout_thread is not None and self._stdout_thread.is_alive()
238
+ ),
239
+ "stderr_thread_alive": bool(
240
+ self._stderr_thread is not None and self._stderr_thread.is_alive()
241
+ ),
242
+ }
243
+
244
+ def _has_live_process_or_threads(self) -> bool:
245
+ process = self._process
246
+ return bool(
247
+ (process is not None and process.poll() is None)
248
+ or (self._stdout_thread is not None and self._stdout_thread.is_alive())
249
+ or (self._stderr_thread is not None and self._stderr_thread.is_alive())
250
+ )
251
+
252
+ def stderr_tail(self) -> str:
253
+ with self._stderr_state_condition:
254
+ joined = "\n".join(self._stderr_tail)
255
+ if len(joined) <= _STDERR_CHAR_LIMIT:
256
+ return joined
257
+ return joined[-_STDERR_CHAR_LIMIT:]
258
+
259
+ def _context(self) -> str:
260
+ return f"server '{self.server.id}' (command '{self.server.command}')"
261
+
262
+ def _format_error_message(self, message: str, *, stderr_tail: str = "") -> str:
263
+ full_message = f"MCP stdio {self._context()}: {message}"
264
+ cleaned_tail = str(stderr_tail or "").strip()
265
+ if cleaned_tail:
266
+ full_message += f"\nstderr tail:\n{cleaned_tail}"
267
+ return full_message
268
+
269
+ def _build_error(
270
+ self,
271
+ message: str,
272
+ *,
273
+ exc_type: type[McpStdioTransportError] = McpStdioTransportError,
274
+ ) -> McpStdioTransportError:
275
+ return exc_type(self._format_error_message(message, stderr_tail=self.stderr_tail().strip()))
276
+
277
+ def _build_fatal_state(
278
+ self,
279
+ message: str,
280
+ *,
281
+ exc_type: type[McpStdioTransportError] = McpStdioTransportError,
282
+ ) -> _FatalTransportState:
283
+ with self._stderr_state_condition:
284
+ stderr_sequence = self._stderr_append_sequence
285
+ stderr_read_sequence = self._stderr_read_sequence
286
+ stderr_last_activity_monotonic = self._stderr_last_activity_monotonic
287
+ return _FatalTransportState(
288
+ message=message,
289
+ exc_type=exc_type,
290
+ stderr_append_sequence=stderr_sequence,
291
+ stderr_read_sequence=stderr_read_sequence,
292
+ stderr_last_activity_monotonic=stderr_last_activity_monotonic,
293
+ fatal_monotonic=time.monotonic(),
294
+ )
295
+
296
+ def _set_fatal_error(self, fatal_state: _FatalTransportState) -> None:
297
+ with self._pending_lock:
298
+ if self._fatal_state is not None:
299
+ return
300
+ self._fatal_state = fatal_state
301
+ pending = [entry.response_queue for entry in self._pending.values()]
302
+ self._pending.clear()
303
+ with self._stdout_state_condition:
304
+ self._stdout_state_condition.notify_all()
305
+ with self._stderr_state_condition:
306
+ self._stderr_state_condition.notify_all()
307
+ for response_queue in pending:
308
+ response_queue.put(fatal_state)
309
+
310
+ def _set_request_state_locked(self, request_id: int | str, state: str) -> None:
311
+ if state == _REQUEST_STATE_COMPLETED:
312
+ self._request_states.pop(request_id, None)
313
+ return
314
+ self._request_states[request_id] = state
315
+ if state not in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
316
+ return
317
+ self._retired_request_state_ids.append(request_id)
318
+ while len(self._retired_request_state_ids) > _RETIRED_REQUEST_STATE_RETENTION_LIMIT:
319
+ retired_request_id = self._retired_request_state_ids.popleft()
320
+ if (
321
+ self._request_states.get(retired_request_id)
322
+ in _REQUEST_STATES_DISCARD_LATE_RESPONSES
323
+ ):
324
+ self._request_states.pop(retired_request_id, None)
325
+
326
+ def _finalize_fatal_error(self, fatal_state: _FatalTransportState) -> McpStdioTransportError:
327
+ stderr_tail = self._wait_for_stderr_quiescence(
328
+ after_sequence=fatal_state.stderr_append_sequence,
329
+ after_read_sequence=fatal_state.stderr_read_sequence,
330
+ quiet_since=max(
331
+ fatal_state.stderr_last_activity_monotonic,
332
+ fatal_state.fatal_monotonic,
333
+ ),
334
+ )
335
+ return fatal_state.exc_type(
336
+ self._format_error_message(
337
+ fatal_state.message,
338
+ stderr_tail=stderr_tail,
339
+ )
340
+ )
341
+
342
+ def _raise_if_fatal(self) -> None:
343
+ if self._fatal_state is None:
344
+ return
345
+ raise self._finalize_fatal_error(self._fatal_state)
346
+
347
+ def _fd_bytes_available(self, fd: int) -> bool:
348
+ if fd < 0:
349
+ return False
350
+ if os.name == "nt":
351
+ try:
352
+ import msvcrt
353
+
354
+ handle = msvcrt.get_osfhandle(fd)
355
+ available = ctypes.c_ulong(0)
356
+ ok = ctypes.windll.kernel32.PeekNamedPipe( # type: ignore[attr-defined]
357
+ ctypes.c_void_p(handle),
358
+ None,
359
+ 0,
360
+ None,
361
+ ctypes.byref(available),
362
+ None,
363
+ )
364
+ return bool(ok) and available.value > 0
365
+ except Exception:
366
+ return False
367
+ try:
368
+ readable, _, _ = select.select([fd], [], [], 0)
369
+ except Exception:
370
+ return False
371
+ return bool(readable)
372
+
373
+ def _read_fd_chunk(self, fd: int, *, size: int) -> bytes:
374
+ while True:
375
+ try:
376
+ return os.read(fd, size)
377
+ except InterruptedError:
378
+ continue
379
+
380
+ def _wait_for_fd_readable(self, fd: int, *, timeout_s: float) -> bool:
381
+ if timeout_s <= 0:
382
+ return self._fd_bytes_available(fd)
383
+ if os.name != "nt":
384
+ try:
385
+ readable, _, _ = select.select([fd], [], [], timeout_s)
386
+ except Exception:
387
+ pass
388
+ else:
389
+ return bool(readable)
390
+ deadline = time.monotonic() + timeout_s
391
+ while True:
392
+ if self._fd_bytes_available(fd):
393
+ return True
394
+ process = self._process
395
+ if process is not None and process.poll() is not None:
396
+ return True
397
+ remaining = deadline - time.monotonic()
398
+ if remaining <= 0:
399
+ return False
400
+ time.sleep(min(_FD_READY_POLL_INTERVAL_S, remaining))
401
+
402
+ def _mark_stdout_quiescent(self) -> int:
403
+ # A new generation is published only after the reader has drained every
404
+ # currently available stdout line and is about to block for more input.
405
+ with self._stdout_state_condition:
406
+ self._stdout_reader_idle = True
407
+ self._stdout_quiescence_generation += 1
408
+ generation = self._stdout_quiescence_generation
409
+ self._stdout_state_condition.notify_all()
410
+ return generation
411
+
412
+ def _set_stdout_reader_active(self) -> None:
413
+ with self._stdout_state_condition:
414
+ if not self._stdout_reader_idle:
415
+ return
416
+ self._stdout_reader_idle = False
417
+ self._stdout_state_condition.notify_all()
418
+
419
+ def _mark_stdout_reader_closed(self) -> None:
420
+ with self._stdout_state_condition:
421
+ self._stdout_reader_idle = True
422
+ self._stdout_reader_closed = True
423
+ self._stdout_quiescence_generation += 1
424
+ self._stdout_state_condition.notify_all()
425
+
426
+ def _mark_stdout_bytes_read(self) -> None:
427
+ with self._stdout_state_condition:
428
+ self._stdout_read_sequence += 1
429
+ self._stdout_last_activity_monotonic = time.monotonic()
430
+ self._stdout_state_condition.notify_all()
431
+
432
+ def _wait_for_post_write_quiescence(
433
+ self,
434
+ *,
435
+ after_generation: int,
436
+ after_read_sequence: int,
437
+ timeout_s: float,
438
+ allow_idle_return_without_activity: bool,
439
+ ) -> None:
440
+ # Notifications have no response marker. The host therefore waits for the
441
+ # next reader-published quiescent boundary after the write. A short no-output
442
+ # grace window handles notifications with no completion timeout. When the
443
+ # caller provides an explicit completion timeout, "no output yet" remains
444
+ # pending until the full timeout elapses so delayed post-write stdout can
445
+ # still surface before success.
446
+ activity_deadline = time.monotonic() + timeout_s
447
+ no_output_deadline = time.monotonic() + (
448
+ _STDOUT_NOTIFICATION_QUIESCENCE_WAIT_S
449
+ if allow_idle_return_without_activity
450
+ else timeout_s
451
+ )
452
+ while True:
453
+ with self._stdout_state_condition:
454
+ if self._fatal_state is not None:
455
+ return
456
+ if self._stdout_reader_closed:
457
+ return
458
+ if self._stdout_quiescence_generation > after_generation:
459
+ return
460
+ read_sequence = self._stdout_read_sequence
461
+ reader_idle = self._stdout_reader_idle
462
+ observed_post_write_activity = read_sequence > after_read_sequence
463
+ if not observed_post_write_activity and reader_idle:
464
+ remaining = no_output_deadline - time.monotonic()
465
+ if remaining <= 0:
466
+ return
467
+ else:
468
+ remaining = activity_deadline - time.monotonic()
469
+ if remaining <= 0:
470
+ raise self._build_error(
471
+ "timed out waiting for post-notification stdout quiescence.",
472
+ exc_type=McpStdioTransportTimeoutError,
473
+ )
474
+ with self._stdout_state_condition:
475
+ if self._fatal_state is not None:
476
+ return
477
+ if self._stdout_reader_closed:
478
+ return
479
+ if self._stdout_quiescence_generation > after_generation:
480
+ return
481
+ if not observed_post_write_activity and reader_idle:
482
+ remaining = no_output_deadline - time.monotonic()
483
+ else:
484
+ remaining = activity_deadline - time.monotonic()
485
+ if remaining <= 0:
486
+ if not observed_post_write_activity and reader_idle:
487
+ return
488
+ raise self._build_error(
489
+ "timed out waiting for post-notification stdout quiescence.",
490
+ exc_type=McpStdioTransportTimeoutError,
491
+ )
492
+ self._stdout_state_condition.wait(timeout=remaining)
493
+
494
+ def _append_stderr_line(self, line: str) -> None:
495
+ cleaned = line.rstrip("\r")
496
+ if not cleaned:
497
+ return
498
+ with self._stderr_state_condition:
499
+ self._stderr_tail.append(cleaned)
500
+ self._stderr_append_sequence += 1
501
+ self._stderr_last_activity_monotonic = time.monotonic()
502
+ self._stderr_state_condition.notify_all()
503
+
504
+ def _mark_stderr_quiescent(self) -> int:
505
+ with self._stderr_state_condition:
506
+ self._stderr_reader_idle = True
507
+ self._stderr_quiescence_generation += 1
508
+ generation = self._stderr_quiescence_generation
509
+ self._stderr_state_condition.notify_all()
510
+ return generation
511
+
512
+ def _mark_stderr_bytes_read(self) -> None:
513
+ with self._stderr_state_condition:
514
+ self._stderr_read_sequence += 1
515
+ self._stderr_last_activity_monotonic = time.monotonic()
516
+ self._stderr_state_condition.notify_all()
517
+
518
+ def _set_stderr_reader_active(self) -> None:
519
+ with self._stderr_state_condition:
520
+ if not self._stderr_reader_idle:
521
+ return
522
+ self._stderr_reader_idle = False
523
+ self._stderr_state_condition.notify_all()
524
+
525
+ def _mark_stderr_reader_closed(self) -> None:
526
+ with self._stderr_state_condition:
527
+ self._stderr_reader_idle = True
528
+ self._stderr_reader_closed = True
529
+ self._stderr_quiescence_generation += 1
530
+ self._stderr_state_condition.notify_all()
531
+
532
+ def _wait_for_stderr_quiescence(
533
+ self,
534
+ *,
535
+ after_sequence: int,
536
+ after_read_sequence: int,
537
+ quiet_since: float,
538
+ ) -> str:
539
+ # Fatal stderr diagnostics stay live until stderr has actually gone quiet
540
+ # after the fatal point; the first later quiescent generation is not enough.
541
+ observed_append_sequence = after_sequence
542
+ observed_read_sequence = after_read_sequence
543
+ while True:
544
+ with self._stderr_state_condition:
545
+ append_sequence = self._stderr_append_sequence
546
+ read_sequence = self._stderr_read_sequence
547
+ last_activity_monotonic = self._stderr_last_activity_monotonic
548
+ reader_closed = self._stderr_reader_closed
549
+ if (
550
+ append_sequence > observed_append_sequence
551
+ or read_sequence > observed_read_sequence
552
+ ):
553
+ observed_append_sequence = append_sequence
554
+ observed_read_sequence = read_sequence
555
+ quiet_since = max(quiet_since, last_activity_monotonic)
556
+ if reader_closed:
557
+ joined = "\n".join(self._stderr_tail)
558
+ break
559
+ remaining = (quiet_since + _STDERR_QUIESCENCE_WAIT_S) - time.monotonic()
560
+ if remaining <= 0:
561
+ joined = "\n".join(self._stderr_tail)
562
+ break
563
+ self._stderr_state_condition.wait(timeout=remaining)
564
+ if len(joined) <= _STDERR_CHAR_LIMIT:
565
+ return joined.strip()
566
+ return joined[-_STDERR_CHAR_LIMIT:].strip()
567
+
568
+ def _wait_for_deferred_response_follow_up(
569
+ self,
570
+ *,
571
+ stdout_fd: int,
572
+ deferred_responses: Mapping[int | str, _DeferredResponse],
573
+ ) -> bool:
574
+ with self._stdout_state_condition:
575
+ quiet_deadline = (
576
+ self._stdout_last_activity_monotonic + _STDOUT_RESPONSE_QUIESCENCE_WAIT_S
577
+ )
578
+ earliest_follow_up_deadline = min(
579
+ deferred.follow_up_deadline_monotonic for deferred in deferred_responses.values()
580
+ )
581
+ remaining = min(
582
+ quiet_deadline - time.monotonic(),
583
+ earliest_follow_up_deadline - time.monotonic(),
584
+ )
585
+ if remaining <= 0:
586
+ return False
587
+ return self._wait_for_fd_readable(stdout_fd, timeout_s=remaining)
588
+
589
+ def _deferred_response_ids_ready_for_release(
590
+ self,
591
+ deferred_responses: Mapping[int | str, _DeferredResponse],
592
+ ) -> tuple[int | str, ...]:
593
+ if not deferred_responses:
594
+ return ()
595
+ with self._stdout_state_condition:
596
+ quiet_deadline = (
597
+ self._stdout_last_activity_monotonic + _STDOUT_RESPONSE_QUIESCENCE_WAIT_S
598
+ )
599
+ now = time.monotonic()
600
+ if now >= quiet_deadline:
601
+ return tuple(deferred_responses.keys())
602
+ return ()
603
+
604
+ def _deferred_response_ids_timed_out(
605
+ self,
606
+ deferred_responses: Mapping[int | str, _DeferredResponse],
607
+ ) -> tuple[int | str, ...]:
608
+ now = time.monotonic()
609
+ return tuple(
610
+ request_id
611
+ for request_id, deferred in deferred_responses.items()
612
+ if now >= deferred.follow_up_deadline_monotonic
613
+ )
614
+
615
+ def start(self) -> None:
616
+ if self._started:
617
+ self._raise_if_fatal()
618
+ return
619
+ if self._closed:
620
+ raise self._build_error("transport is already closed.")
621
+ try:
622
+ self._process = subprocess.Popen(
623
+ [self.server.command, *self.server.args],
624
+ cwd=self.workspace_root,
625
+ env=self.spawn_env,
626
+ stdin=subprocess.PIPE,
627
+ stdout=subprocess.PIPE,
628
+ stderr=subprocess.PIPE,
629
+ bufsize=0,
630
+ )
631
+ except Exception as exc: # noqa: BLE001
632
+ raise self._build_error(f"failed to launch process: {exc}") from exc
633
+ self._started = True
634
+ _LIVE_STDIO_TRANSPORTS.add(self)
635
+ self._stdout_thread = threading.Thread(
636
+ target=self._stdout_reader,
637
+ name=f"mcp-stdout-{self.server.id}",
638
+ daemon=True,
639
+ )
640
+ self._stderr_thread = threading.Thread(
641
+ target=self._stderr_reader,
642
+ name=f"mcp-stderr-{self.server.id}",
643
+ daemon=True,
644
+ )
645
+ self._stdout_thread.start()
646
+ self._stderr_thread.start()
647
+
648
+ def _stderr_reader(self) -> None:
649
+ process = self._process
650
+ if process is None or process.stderr is None:
651
+ return
652
+ try:
653
+ stderr_fd = process.stderr.fileno()
654
+ except Exception:
655
+ self._mark_stderr_reader_closed()
656
+ return
657
+ buffered_bytes = bytearray()
658
+ self._mark_stderr_quiescent()
659
+ try:
660
+ while True:
661
+ chunk = self._read_fd_chunk(stderr_fd, size=_STDERR_READ_CHUNK_SIZE)
662
+ if not chunk:
663
+ if buffered_bytes:
664
+ self._append_stderr_line(
665
+ bytes(buffered_bytes).decode("utf-8", errors="replace")
666
+ )
667
+ buffered_bytes.clear()
668
+ break
669
+ self._set_stderr_reader_active()
670
+ self._mark_stderr_bytes_read()
671
+ buffered_bytes.extend(chunk)
672
+ self._drain_stderr_lines(buffered_bytes)
673
+ while self._fd_bytes_available(stderr_fd):
674
+ extra_chunk = self._read_fd_chunk(
675
+ stderr_fd,
676
+ size=_STDERR_READ_CHUNK_SIZE,
677
+ )
678
+ if not extra_chunk:
679
+ break
680
+ self._mark_stderr_bytes_read()
681
+ buffered_bytes.extend(extra_chunk)
682
+ self._drain_stderr_lines(buffered_bytes)
683
+ if not buffered_bytes:
684
+ self._mark_stderr_quiescent()
685
+ except Exception: # noqa: BLE001
686
+ pass
687
+ finally:
688
+ self._mark_stderr_reader_closed()
689
+
690
+ def _stdout_reader(self) -> None:
691
+ process = self._process
692
+ if process is None or process.stdout is None:
693
+ return
694
+ try:
695
+ stdout_fd = process.stdout.fileno()
696
+ except Exception:
697
+ self._set_fatal_error(self._build_fatal_state("stdout binary stream is unavailable."))
698
+ return
699
+ buffered_bytes = bytearray()
700
+ deferred_responses: dict[int | str, _DeferredResponse] = {}
701
+ self._mark_stdout_quiescent()
702
+ try:
703
+ while True:
704
+ if not buffered_bytes and deferred_responses and self._fatal_state is None:
705
+ if not self._wait_for_deferred_response_follow_up(
706
+ stdout_fd=stdout_fd,
707
+ deferred_responses=deferred_responses,
708
+ ):
709
+ self._mark_stdout_quiescent()
710
+ self._flush_deferred_responses(deferred_responses)
711
+ continue
712
+ chunk = self._read_fd_chunk(stdout_fd, size=_STDOUT_READ_CHUNK_SIZE)
713
+ if not chunk:
714
+ if buffered_bytes:
715
+ trailing_line = _decode_stdout_protocol_line(bytes(buffered_bytes))
716
+ for message in parse_jsonrpc_line(trailing_line):
717
+ self._handle_incoming_message(
718
+ message,
719
+ deferred_responses=deferred_responses,
720
+ )
721
+ buffered_bytes.clear()
722
+ break
723
+ self._set_stdout_reader_active()
724
+ self._mark_stdout_bytes_read()
725
+ buffered_bytes.extend(chunk)
726
+ self._drain_stdout_lines(
727
+ buffered_bytes,
728
+ deferred_responses=deferred_responses,
729
+ )
730
+ while self._fd_bytes_available(stdout_fd):
731
+ extra_chunk = self._read_fd_chunk(
732
+ stdout_fd,
733
+ size=_STDOUT_READ_CHUNK_SIZE,
734
+ )
735
+ if not extra_chunk:
736
+ break
737
+ self._mark_stdout_bytes_read()
738
+ buffered_bytes.extend(extra_chunk)
739
+ self._drain_stdout_lines(
740
+ buffered_bytes,
741
+ deferred_responses=deferred_responses,
742
+ )
743
+ if not buffered_bytes and not deferred_responses:
744
+ self._mark_stdout_quiescent()
745
+ self._flush_deferred_responses(deferred_responses)
746
+ except JsonRpcProtocolError as exc:
747
+ self._set_fatal_error(
748
+ self._build_fatal_state(
749
+ f"received malformed JSON-RPC payload: {exc}",
750
+ exc_type=McpStdioTransportProtocolError,
751
+ )
752
+ )
753
+ except McpStdioTransportProtocolError as exc:
754
+ self._set_fatal_error(
755
+ self._build_fatal_state(
756
+ str(exc),
757
+ exc_type=McpStdioTransportProtocolError,
758
+ )
759
+ )
760
+ except Exception as exc: # noqa: BLE001
761
+ if not self._closing:
762
+ self._set_fatal_error(self._build_fatal_state(f"stdout reader failed: {exc}"))
763
+ finally:
764
+ self._mark_stdout_reader_closed()
765
+ if not self._closing:
766
+ return_code = process.poll()
767
+ detail = "stdio process exited unexpectedly"
768
+ if return_code is not None:
769
+ detail = f"stdio process exited with code {return_code}"
770
+ self._set_fatal_error(self._build_fatal_state(detail))
771
+
772
+ def _drain_stderr_lines(self, buffered_bytes: bytearray) -> None:
773
+ while True:
774
+ newline_index = buffered_bytes.find(b"\n")
775
+ if newline_index < 0:
776
+ return
777
+ line_bytes = bytes(buffered_bytes[:newline_index])
778
+ del buffered_bytes[: newline_index + 1]
779
+ self._append_stderr_line(line_bytes.decode("utf-8", errors="replace"))
780
+
781
+ def _drain_stdout_lines(
782
+ self,
783
+ buffered_bytes: bytearray,
784
+ *,
785
+ deferred_responses: dict[int | str, _DeferredResponse],
786
+ ) -> None:
787
+ while True:
788
+ newline_index = buffered_bytes.find(b"\n")
789
+ if newline_index < 0:
790
+ return
791
+ line_bytes = bytes(buffered_bytes[:newline_index])
792
+ del buffered_bytes[: newline_index + 1]
793
+ line = _decode_stdout_protocol_line(line_bytes)
794
+ for message in parse_jsonrpc_line(line):
795
+ self._handle_incoming_message(
796
+ message,
797
+ deferred_responses=deferred_responses,
798
+ )
799
+
800
+ def _flush_deferred_responses(
801
+ self, deferred_responses: dict[int | str, _DeferredResponse]
802
+ ) -> None:
803
+ # Responses stay hidden until the reader reaches the current stdout quiet
804
+ # boundary. If later stdout activity moves that boundary forward and the
805
+ # bounded follow-up budget expires first, the active call fails instead.
806
+ if not deferred_responses or self._fatal_state is not None:
807
+ deferred_responses.clear()
808
+ return
809
+ ready_ids = self._deferred_response_ids_ready_for_release(deferred_responses)
810
+ timed_out_ids = self._deferred_response_ids_timed_out(deferred_responses)
811
+ if timed_out_ids:
812
+ timed_out_id_set = set(timed_out_ids)
813
+ ready_ids = tuple(
814
+ request_id for request_id in ready_ids if request_id not in timed_out_id_set
815
+ )
816
+ if not ready_ids and not timed_out_ids:
817
+ return
818
+ queued: list[int | str] = []
819
+ unknown_request_id: int | str | None = None
820
+ duplicate_request_id: int | str | None = None
821
+ expiring_ids = timed_out_ids
822
+ releasable_ids = ready_ids
823
+ with self._pending_lock:
824
+ for request_id in expiring_ids:
825
+ pending_request = self._pending.get(request_id)
826
+ request_state = self._request_states.get(request_id)
827
+ if pending_request is None:
828
+ if request_state in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
829
+ queued.append(request_id)
830
+ continue
831
+ if request_state == _REQUEST_STATE_COMPLETED:
832
+ duplicate_request_id = request_id
833
+ break
834
+ unknown_request_id = request_id
835
+ break
836
+ if request_state == _REQUEST_STATE_AWAITING_FOLLOW_UP:
837
+ pending_request.response_queue.put(_DeferredResponseFollowUpTimeout())
838
+ self._pending.pop(request_id, None)
839
+ self._set_request_state_locked(request_id, _REQUEST_STATE_FOLLOW_UP_TIMED_OUT)
840
+ queued.append(request_id)
841
+ continue
842
+ if request_state in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
843
+ self._pending.pop(request_id, None)
844
+ queued.append(request_id)
845
+ continue
846
+ unknown_request_id = request_id
847
+ break
848
+ if unknown_request_id is None and duplicate_request_id is None:
849
+ for request_id in releasable_ids:
850
+ deferred = deferred_responses.get(request_id)
851
+ if deferred is None:
852
+ continue
853
+ pending_request = self._pending.get(request_id)
854
+ request_state = self._request_states.get(request_id)
855
+ if pending_request is None:
856
+ if request_state in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
857
+ queued.append(request_id)
858
+ continue
859
+ if request_state == _REQUEST_STATE_COMPLETED:
860
+ duplicate_request_id = request_id
861
+ break
862
+ unknown_request_id = request_id
863
+ break
864
+ if request_state == _REQUEST_STATE_AWAITING_FOLLOW_UP:
865
+ pending_request.response_queue.put(
866
+ _QueuedResponse(response=deferred.response)
867
+ )
868
+ self._pending.pop(request_id, None)
869
+ self._set_request_state_locked(request_id, _REQUEST_STATE_COMPLETED)
870
+ queued.append(request_id)
871
+ continue
872
+ if request_state in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
873
+ self._pending.pop(request_id, None)
874
+ queued.append(request_id)
875
+ continue
876
+ unknown_request_id = request_id
877
+ break
878
+ if duplicate_request_id is not None:
879
+ self._set_fatal_error(
880
+ self._build_fatal_state(
881
+ f"received duplicate response for request id {duplicate_request_id!r}",
882
+ exc_type=McpStdioTransportProtocolError,
883
+ )
884
+ )
885
+ return
886
+ if unknown_request_id is not None:
887
+ self._set_fatal_error(
888
+ self._build_fatal_state(
889
+ f"received response for unknown request id {unknown_request_id!r}",
890
+ exc_type=McpStdioTransportProtocolError,
891
+ )
892
+ )
893
+ return
894
+ for request_id in queued:
895
+ deferred_responses.pop(request_id, None)
896
+
897
+ def _handle_response_message(
898
+ self,
899
+ message: JsonRpcResponse,
900
+ *,
901
+ deferred_responses: dict[int | str, _DeferredResponse],
902
+ ) -> None:
903
+ if message.id in deferred_responses:
904
+ self._set_fatal_error(
905
+ self._build_fatal_state(
906
+ f"received duplicate response for request id {message.id!r}",
907
+ exc_type=McpStdioTransportProtocolError,
908
+ )
909
+ )
910
+ return
911
+ fatal_message: str | None = None
912
+ with self._pending_lock:
913
+ pending_request = self._pending.get(message.id)
914
+ request_state = self._request_states.get(message.id)
915
+ if pending_request is None:
916
+ if request_state in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
917
+ return
918
+ if request_state == _REQUEST_STATE_COMPLETED:
919
+ fatal_message = f"received duplicate response for request id {message.id!r}"
920
+ else:
921
+ fatal_message = f"received response for unknown request id {message.id!r}"
922
+ elif request_state == _REQUEST_STATE_AWAITING_RESPONSE:
923
+ follow_up_deadline = time.monotonic() + pending_request.follow_up_budget_s
924
+ pending_request.response_queue.put(
925
+ _ObservedResponse(follow_up_deadline_monotonic=follow_up_deadline)
926
+ )
927
+ self._set_request_state_locked(message.id, _REQUEST_STATE_AWAITING_FOLLOW_UP)
928
+ deferred_responses[message.id] = _DeferredResponse(
929
+ response=message,
930
+ follow_up_deadline_monotonic=follow_up_deadline,
931
+ )
932
+ return
933
+ elif request_state == _REQUEST_STATE_AWAITING_FOLLOW_UP:
934
+ fatal_message = f"received duplicate response for request id {message.id!r}"
935
+ elif request_state in _REQUEST_STATES_DISCARD_LATE_RESPONSES:
936
+ self._pending.pop(message.id, None)
937
+ return
938
+ else:
939
+ fatal_message = f"received response for unknown request id {message.id!r}"
940
+ if fatal_message is not None:
941
+ self._set_fatal_error(
942
+ self._build_fatal_state(
943
+ fatal_message,
944
+ exc_type=McpStdioTransportProtocolError,
945
+ )
946
+ )
947
+
948
+ def _retire_request_on_timeout(
949
+ self,
950
+ *,
951
+ request_id: int | str,
952
+ observed_response: bool,
953
+ ) -> None:
954
+ with self._pending_lock:
955
+ pending_request = self._pending.pop(request_id, None)
956
+ request_state = self._request_states.get(request_id)
957
+ if pending_request is None and request_state == _REQUEST_STATE_COMPLETED:
958
+ return
959
+ if observed_response or request_state == _REQUEST_STATE_AWAITING_FOLLOW_UP:
960
+ self._set_request_state_locked(request_id, _REQUEST_STATE_FOLLOW_UP_TIMED_OUT)
961
+ return
962
+ self._set_request_state_locked(request_id, _REQUEST_STATE_RESPONSE_TIMED_OUT)
963
+
964
+ def _send_serialized(self, payload: dict[str, Any]) -> None:
965
+ process = self._process
966
+ if process is None or process.stdin is None:
967
+ raise self._build_error("stdin is unavailable.")
968
+ if process.poll() is not None:
969
+ raise self._build_error(f"stdio process already exited with code {process.poll()}")
970
+ with self._write_lock:
971
+ try:
972
+ process.stdin.write((encode_jsonrpc_message(payload) + "\n").encode("utf-8"))
973
+ process.stdin.flush()
974
+ except Exception as exc: # noqa: BLE001
975
+ raise self._build_error(f"failed to write request: {exc}") from exc
976
+
977
+ def _fatal_unsupported_server_request(self, method: str) -> None:
978
+ self._set_fatal_error(
979
+ self._build_fatal_state(
980
+ f"received unsupported server-initiated request '{method}'",
981
+ exc_type=McpStdioTransportProtocolError,
982
+ )
983
+ )
984
+
985
+ def _handle_server_request_message(self, message: JsonRpcRequest) -> None:
986
+ if self._server_request_handler is None or self._server_request_context is None:
987
+ try:
988
+ self._send_serialized(
989
+ build_jsonrpc_error_response(
990
+ request_id=message.id,
991
+ code=_UNSUPPORTED_CLIENT_REQUEST_CODE,
992
+ message=(
993
+ "Server-initiated requests are not supported by this MCP host runtime."
994
+ ),
995
+ )
996
+ )
997
+ except Exception:
998
+ pass
999
+ self._fatal_unsupported_server_request(message.method)
1000
+ return
1001
+ try:
1002
+ result = self._server_request_handler.handle_request(
1003
+ context=self._server_request_context,
1004
+ method=message.method,
1005
+ request_id=message.id,
1006
+ params=message.params,
1007
+ )
1008
+ except McpUnsupportedServerRequestError as exc:
1009
+ try:
1010
+ self._send_serialized(
1011
+ build_jsonrpc_error_response(
1012
+ request_id=message.id,
1013
+ code=exc.code,
1014
+ message=exc.message,
1015
+ )
1016
+ )
1017
+ except Exception:
1018
+ pass
1019
+ self._fatal_unsupported_server_request(message.method)
1020
+ return
1021
+ except McpServerRequestHandlerError as exc:
1022
+ try:
1023
+ self._send_serialized(
1024
+ build_jsonrpc_error_response(
1025
+ request_id=message.id,
1026
+ code=exc.code,
1027
+ message=exc.message,
1028
+ )
1029
+ )
1030
+ except Exception:
1031
+ pass
1032
+ self._set_fatal_error(
1033
+ self._build_fatal_state(
1034
+ f"failed to handle server-initiated request '{message.method}'",
1035
+ exc_type=McpStdioTransportProtocolError,
1036
+ )
1037
+ )
1038
+ return
1039
+ except Exception:
1040
+ try:
1041
+ self._send_serialized(
1042
+ build_jsonrpc_error_response(
1043
+ request_id=message.id,
1044
+ code=_INTERNAL_CLIENT_REQUEST_CODE,
1045
+ message="Internal MCP host error while handling server-initiated request.",
1046
+ )
1047
+ )
1048
+ except Exception:
1049
+ pass
1050
+ self._set_fatal_error(
1051
+ self._build_fatal_state(
1052
+ f"failed to handle server-initiated request '{message.method}'",
1053
+ exc_type=McpStdioTransportProtocolError,
1054
+ )
1055
+ )
1056
+ return
1057
+ try:
1058
+ self._send_serialized(
1059
+ build_jsonrpc_result_response(
1060
+ request_id=message.id,
1061
+ result=result,
1062
+ )
1063
+ )
1064
+ except Exception as exc:
1065
+ self._set_fatal_error(
1066
+ self._build_fatal_state(
1067
+ f"failed to respond to server-initiated request '{message.method}': {exc}",
1068
+ exc_type=McpStdioTransportProtocolError,
1069
+ )
1070
+ )
1071
+
1072
+ def _handle_incoming_message(
1073
+ self,
1074
+ message: JsonRpcRequest | JsonRpcNotification | JsonRpcResponse,
1075
+ *,
1076
+ deferred_responses: dict[int | str, _DeferredResponse],
1077
+ ) -> None:
1078
+ if isinstance(message, JsonRpcNotification):
1079
+ self._notifications.put(message)
1080
+ return
1081
+ if isinstance(message, JsonRpcResponse):
1082
+ self._handle_response_message(
1083
+ message,
1084
+ deferred_responses=deferred_responses,
1085
+ )
1086
+ return
1087
+ self._handle_server_request_message(message)
1088
+
1089
+ def request(
1090
+ self,
1091
+ *,
1092
+ method: str,
1093
+ params: dict[str, Any] | None,
1094
+ timeout_s: float,
1095
+ ) -> JsonRpcResponse:
1096
+ if timeout_s <= 0:
1097
+ raise self._build_error("timeout_s must be > 0.")
1098
+ self.start()
1099
+ self._raise_if_fatal()
1100
+ request_id = self._id_generator.next()
1101
+ response_queue: queue.Queue[Any] = queue.Queue()
1102
+ response_deadline = time.monotonic() + timeout_s
1103
+ with self._pending_lock:
1104
+ self._pending[request_id] = _PendingRequest(
1105
+ response_queue=response_queue,
1106
+ follow_up_budget_s=_STDOUT_RESPONSE_FOLLOW_UP_BUDGET_S,
1107
+ )
1108
+ self._set_request_state_locked(request_id, _REQUEST_STATE_AWAITING_RESPONSE)
1109
+ try:
1110
+ self._send_serialized(
1111
+ build_jsonrpc_request(
1112
+ request_id=request_id,
1113
+ method=method,
1114
+ params=params,
1115
+ )
1116
+ )
1117
+ except Exception:
1118
+ with self._pending_lock:
1119
+ self._pending.pop(request_id, None)
1120
+ self._set_request_state_locked(request_id, _REQUEST_STATE_CANCELLED)
1121
+ raise
1122
+ current_deadline = response_deadline
1123
+ observed_response = False
1124
+ while True:
1125
+ remaining = current_deadline - time.monotonic()
1126
+ if remaining <= 0:
1127
+ try:
1128
+ response = response_queue.get_nowait()
1129
+ except queue.Empty as exc:
1130
+ self._retire_request_on_timeout(
1131
+ request_id=request_id,
1132
+ observed_response=observed_response,
1133
+ )
1134
+ message = f"timed out waiting for response to '{method}' after {timeout_s:.3f}s"
1135
+ if observed_response:
1136
+ message = (
1137
+ f"timed out waiting for post-response stdout follow-up for "
1138
+ f"'{method}' after {timeout_s:.3f}s"
1139
+ )
1140
+ raise self._build_error(
1141
+ message,
1142
+ exc_type=McpStdioTransportTimeoutError,
1143
+ ) from exc
1144
+ else:
1145
+ try:
1146
+ response = response_queue.get(timeout=remaining)
1147
+ except queue.Empty:
1148
+ continue
1149
+ if isinstance(response, _ObservedResponse):
1150
+ observed_response = True
1151
+ current_deadline = response.follow_up_deadline_monotonic
1152
+ continue
1153
+ if isinstance(response, _DeferredResponseFollowUpTimeout):
1154
+ observed_response = True
1155
+ raise self._build_error(
1156
+ f"timed out waiting for post-response stdout follow-up for "
1157
+ f"'{method}' after {timeout_s:.3f}s",
1158
+ exc_type=McpStdioTransportTimeoutError,
1159
+ )
1160
+ if isinstance(response, _FatalTransportState):
1161
+ raise self._finalize_fatal_error(response)
1162
+ if not isinstance(response, _QueuedResponse):
1163
+ raise self._build_error(
1164
+ f"received invalid response object for '{method}'",
1165
+ exc_type=McpStdioTransportProtocolError,
1166
+ )
1167
+ self._raise_if_fatal()
1168
+ return response.response
1169
+
1170
+ def send_notification(
1171
+ self,
1172
+ *,
1173
+ method: str,
1174
+ params: dict[str, Any] | None,
1175
+ completion_timeout_s: float | None = None,
1176
+ ) -> None:
1177
+ self.start()
1178
+ self._raise_if_fatal()
1179
+ with self._stdout_state_condition:
1180
+ post_write_generation = self._stdout_quiescence_generation
1181
+ post_write_read_sequence = self._stdout_read_sequence
1182
+ self._send_serialized(
1183
+ build_jsonrpc_notification(
1184
+ method=method,
1185
+ params=params,
1186
+ )
1187
+ )
1188
+ self._wait_for_post_write_quiescence(
1189
+ after_generation=post_write_generation,
1190
+ after_read_sequence=post_write_read_sequence,
1191
+ timeout_s=max(
1192
+ completion_timeout_s or _STDOUT_NOTIFICATION_QUIESCENCE_WAIT_S,
1193
+ _STDOUT_NOTIFICATION_QUIESCENCE_WAIT_S,
1194
+ ),
1195
+ allow_idle_return_without_activity=completion_timeout_s is None,
1196
+ )
1197
+ self._raise_if_fatal()
1198
+
1199
+ def drain_notifications(self) -> tuple[JsonRpcNotification, ...]:
1200
+ self._raise_if_fatal()
1201
+ notifications: list[JsonRpcNotification] = []
1202
+ while True:
1203
+ try:
1204
+ notifications.append(self._notifications.get_nowait())
1205
+ except queue.Empty:
1206
+ break
1207
+ self._raise_if_fatal()
1208
+ return tuple(notifications)
1209
+
1210
+ def close(self) -> None:
1211
+ if self._closed:
1212
+ return
1213
+ self._closed = True
1214
+ self._closing = True
1215
+ with self._pending_lock:
1216
+ self._pending.clear()
1217
+ self._request_states.clear()
1218
+ self._retired_request_state_ids.clear()
1219
+ process = self._process
1220
+ if process is None:
1221
+ return
1222
+ try:
1223
+ if process.stdin is not None:
1224
+ process.stdin.close()
1225
+ except Exception:
1226
+ pass
1227
+ try:
1228
+ process.wait(timeout=_SHUTDOWN_WAIT_S)
1229
+ except subprocess.TimeoutExpired:
1230
+ try:
1231
+ process.terminate()
1232
+ process.wait(timeout=_TERMINATE_WAIT_S)
1233
+ except subprocess.TimeoutExpired:
1234
+ process.kill()
1235
+ try:
1236
+ process.wait(timeout=_TERMINATE_WAIT_S)
1237
+ except subprocess.TimeoutExpired:
1238
+ pass
1239
+ finally:
1240
+ for stream in (process.stdout, process.stderr):
1241
+ try:
1242
+ if stream is not None:
1243
+ stream.close()
1244
+ except Exception:
1245
+ pass
1246
+ for thread in (self._stdout_thread, self._stderr_thread):
1247
+ if thread is not None:
1248
+ thread.join(timeout=_READER_THREAD_JOIN_WAIT_S)
1249
+ if not self._has_live_process_or_threads():
1250
+ _LIVE_STDIO_TRANSPORTS.discard(self)
1251
+
1252
+
1253
+ def live_stdio_transport_diagnostics() -> list[dict[str, Any]]:
1254
+ diagnostics: list[dict[str, Any]] = []
1255
+ for transport in list(_LIVE_STDIO_TRANSPORTS):
1256
+ snapshot = transport.diagnostic_snapshot()
1257
+ if (
1258
+ not snapshot["closed"]
1259
+ or snapshot["returncode"] is None
1260
+ or snapshot["stdout_thread_alive"]
1261
+ or snapshot["stderr_thread_alive"]
1262
+ ):
1263
+ diagnostics.append(snapshot)
1264
+ else:
1265
+ _LIVE_STDIO_TRANSPORTS.discard(transport)
1266
+ diagnostics.sort(key=lambda item: (str(item["server_id"]), int(item["transport_id"])))
1267
+ return diagnostics