@oneciel-ai/ciel-runtime 0.1.1 → 0.2.0

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 (287) hide show
  1. package/README.md +132 -0
  2. package/ciel-runtime-menu.py +56 -6
  3. package/ciel_runtime.py +9379 -35909
  4. package/ciel_runtime_support/advisor_client.py +193 -0
  5. package/ciel_runtime_support/advisor_policy.py +320 -0
  6. package/ciel_runtime_support/advisor_refinement.py +160 -0
  7. package/ciel_runtime_support/advisor_request_builder.py +261 -0
  8. package/ciel_runtime_support/agy_installer.py +169 -0
  9. package/ciel_runtime_support/agy_mcp_restore.py +182 -0
  10. package/ciel_runtime_support/anthropic_model_policy.py +186 -0
  11. package/ciel_runtime_support/anthropic_response_writer.py +255 -0
  12. package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
  13. package/ciel_runtime_support/api_key_cooldown.py +159 -0
  14. package/ciel_runtime_support/architecture.py +488 -1
  15. package/ciel_runtime_support/architecture_budget.py +42 -0
  16. package/ciel_runtime_support/channel_backlog.py +90 -0
  17. package/ciel_runtime_support/channel_cli.py +119 -0
  18. package/ciel_runtime_support/channel_compact_injection.py +82 -0
  19. package/ciel_runtime_support/channel_compact_poll.py +67 -0
  20. package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
  21. package/ciel_runtime_support/channel_config_service.py +281 -0
  22. package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
  23. package/ciel_runtime_support/channel_connection_registry.py +128 -0
  24. package/ciel_runtime_support/channel_connection_worker.py +284 -0
  25. package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
  26. package/ciel_runtime_support/channel_cursor_repository.py +89 -0
  27. package/ciel_runtime_support/channel_cursor_service.py +178 -0
  28. package/ciel_runtime_support/channel_event_identity.py +212 -0
  29. package/ciel_runtime_support/channel_event_projection.py +315 -0
  30. package/ciel_runtime_support/channel_inflight.py +127 -0
  31. package/ciel_runtime_support/channel_injection.py +115 -0
  32. package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
  33. package/ciel_runtime_support/channel_launch_policy.py +180 -0
  34. package/ciel_runtime_support/channel_llm_context.py +156 -0
  35. package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
  36. package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
  37. package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
  38. package/ciel_runtime_support/channel_mcp_tools.py +240 -0
  39. package/ciel_runtime_support/channel_mcp_transport.py +394 -0
  40. package/ciel_runtime_support/channel_message_dedupe.py +65 -0
  41. package/ciel_runtime_support/channel_message_policy.py +256 -0
  42. package/ciel_runtime_support/channel_message_prompt.py +305 -0
  43. package/ciel_runtime_support/channel_message_repository.py +234 -0
  44. package/ciel_runtime_support/channel_notification_projection.py +217 -0
  45. package/ciel_runtime_support/channel_panel.py +162 -0
  46. package/ciel_runtime_support/channel_pending_injection.py +209 -0
  47. package/ciel_runtime_support/channel_pending_poll.py +109 -0
  48. package/ciel_runtime_support/channel_probe_cache.py +433 -0
  49. package/ciel_runtime_support/channel_probe_report.py +101 -0
  50. package/ciel_runtime_support/channel_runtime_environment.py +181 -0
  51. package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
  52. package/ciel_runtime_support/channel_session_repository.py +90 -0
  53. package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
  54. package/ciel_runtime_support/channel_terminal_input.py +277 -0
  55. package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
  56. package/ciel_runtime_support/channel_tool_context.py +166 -0
  57. package/ciel_runtime_support/channel_transcript.py +414 -0
  58. package/ciel_runtime_support/channel_transcript_repository.py +96 -0
  59. package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
  60. package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
  61. package/ciel_runtime_support/chat_files.py +138 -0
  62. package/ciel_runtime_support/chat_http_controller.py +235 -0
  63. package/ciel_runtime_support/claude_environment.py +375 -0
  64. package/ciel_runtime_support/claude_router.py +247 -193
  65. package/ciel_runtime_support/cli_dispatch.py +792 -0
  66. package/ciel_runtime_support/cli_parser.py +165 -0
  67. package/ciel_runtime_support/cli_usage.py +100 -0
  68. package/ciel_runtime_support/codex_app_server.py +20 -5
  69. package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
  70. package/ciel_runtime_support/codex_cli.py +42 -6
  71. package/ciel_runtime_support/codex_config.py +323 -0
  72. package/ciel_runtime_support/codex_launch_configuration.py +240 -0
  73. package/ciel_runtime_support/codex_launch_policy.py +66 -0
  74. package/ciel_runtime_support/codex_mcp_integration.py +195 -0
  75. package/ciel_runtime_support/codex_mcp_restore.py +304 -0
  76. package/ciel_runtime_support/codex_model_catalog.py +133 -0
  77. package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
  78. package/ciel_runtime_support/codex_router.py +147 -1
  79. package/ciel_runtime_support/codex_session_repository.py +115 -0
  80. package/ciel_runtime_support/codex_session_selection.py +114 -0
  81. package/ciel_runtime_support/command_asset_installer.py +103 -0
  82. package/ciel_runtime_support/compatibility_probe.py +295 -0
  83. package/ciel_runtime_support/compatibility_protocol.py +251 -0
  84. package/ciel_runtime_support/compatibility_runtime.py +166 -0
  85. package/ciel_runtime_support/compatibility_test.py +370 -0
  86. package/ciel_runtime_support/config_migrations.py +307 -0
  87. package/ciel_runtime_support/config_repository.py +175 -0
  88. package/ciel_runtime_support/config_value_codec.py +64 -0
  89. package/ciel_runtime_support/configuration_cli.py +374 -0
  90. package/ciel_runtime_support/context_compaction.py +280 -0
  91. package/ciel_runtime_support/context_setup.py +208 -0
  92. package/ciel_runtime_support/context_summary_policy.py +392 -0
  93. package/ciel_runtime_support/credential_cli.py +104 -0
  94. package/ciel_runtime_support/credential_management.py +261 -0
  95. package/ciel_runtime_support/credentials.py +269 -0
  96. package/ciel_runtime_support/executable_discovery.py +141 -0
  97. package/ciel_runtime_support/github_copilot_oauth.py +335 -0
  98. package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
  99. package/ciel_runtime_support/header_forwarding.py +73 -0
  100. package/ciel_runtime_support/headless_config.py +221 -0
  101. package/ciel_runtime_support/http_response.py +129 -0
  102. package/ciel_runtime_support/install_diagnostics.py +149 -0
  103. package/ciel_runtime_support/kimi_identity.py +123 -0
  104. package/ciel_runtime_support/launch_diagnostics.py +204 -0
  105. package/ciel_runtime_support/launch_state.py +127 -0
  106. package/ciel_runtime_support/live_api_key_controller.py +58 -0
  107. package/ciel_runtime_support/llm_config_http.py +148 -0
  108. package/ciel_runtime_support/llm_option_config.py +259 -0
  109. package/ciel_runtime_support/llm_presentation_data.py +447 -0
  110. package/ciel_runtime_support/llm_presets.py +773 -0
  111. package/ciel_runtime_support/lm_studio_runtime.py +401 -0
  112. package/ciel_runtime_support/managed_mcp_config.py +144 -0
  113. package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
  114. package/ciel_runtime_support/managed_service_cleanup.py +89 -0
  115. package/ciel_runtime_support/mcp_config_reader.py +230 -0
  116. package/ciel_runtime_support/mcp_http_proxy.py +607 -0
  117. package/ciel_runtime_support/mcp_inventory.py +59 -0
  118. package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
  119. package/ciel_runtime_support/mcp_probe_codec.py +136 -0
  120. package/ciel_runtime_support/mcp_probe_transport.py +328 -0
  121. package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
  122. package/ciel_runtime_support/mcp_proxy_config.py +107 -0
  123. package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
  124. package/ciel_runtime_support/mcp_proxy_process.py +560 -0
  125. package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
  126. package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
  127. package/ciel_runtime_support/mcp_transport.py +146 -0
  128. package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
  129. package/ciel_runtime_support/model_catalog_projection.py +61 -0
  130. package/ciel_runtime_support/model_context_hints.py +109 -0
  131. package/ciel_runtime_support/model_panel.py +147 -0
  132. package/ciel_runtime_support/model_registry_repository.py +231 -0
  133. package/ciel_runtime_support/npm_runtime.py +191 -0
  134. package/ciel_runtime_support/ollama_catalog.py +462 -0
  135. package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
  136. package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
  137. package/ciel_runtime_support/ollama_context_sync.py +87 -0
  138. package/ciel_runtime_support/ollama_forwarding.py +449 -0
  139. package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
  140. package/ciel_runtime_support/openai_chat_router.py +64 -0
  141. package/ciel_runtime_support/openai_forwarding.py +194 -0
  142. package/ciel_runtime_support/openai_responses_router.py +291 -0
  143. package/ciel_runtime_support/openai_responses_stream.py +135 -0
  144. package/ciel_runtime_support/output_budget.py +89 -0
  145. package/ciel_runtime_support/package_lifecycle.py +217 -0
  146. package/ciel_runtime_support/plan_artifact_controller.py +104 -0
  147. package/ciel_runtime_support/prelaunch.py +959 -0
  148. package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
  149. package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
  150. package/ciel_runtime_support/prelaunch_terminal.py +764 -0
  151. package/ciel_runtime_support/process_control.py +708 -0
  152. package/ciel_runtime_support/prompt_compaction.py +322 -0
  153. package/ciel_runtime_support/prompt_injection.py +176 -0
  154. package/ciel_runtime_support/protocols/__init__.py +24 -0
  155. package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
  156. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
  157. package/ciel_runtime_support/protocols/chat_projection.py +315 -0
  158. package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
  159. package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
  160. package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
  161. package/ciel_runtime_support/protocols/ollama_response.py +231 -0
  162. package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
  163. package/ciel_runtime_support/protocols/openai_responses.py +271 -0
  164. package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
  165. package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
  166. package/ciel_runtime_support/provider_adapters.py +160 -0
  167. package/ciel_runtime_support/provider_catalog_sources.py +316 -0
  168. package/ciel_runtime_support/provider_choice.py +201 -0
  169. package/ciel_runtime_support/provider_compatibility.py +165 -0
  170. package/ciel_runtime_support/provider_config_mutations.py +361 -0
  171. package/ciel_runtime_support/provider_configuration_service.py +162 -0
  172. package/ciel_runtime_support/provider_context.py +308 -0
  173. package/ciel_runtime_support/provider_contract_projection.py +73 -0
  174. package/ciel_runtime_support/provider_descriptor.py +82 -0
  175. package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
  176. package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
  177. package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
  178. package/ciel_runtime_support/provider_limits.py +457 -0
  179. package/ciel_runtime_support/provider_model_identity.py +139 -0
  180. package/ciel_runtime_support/provider_model_selection.py +431 -0
  181. package/ciel_runtime_support/provider_model_specs.py +142 -0
  182. package/ciel_runtime_support/provider_models.py +263 -0
  183. package/ciel_runtime_support/provider_network.py +176 -0
  184. package/ciel_runtime_support/provider_option_cli.py +238 -0
  185. package/ciel_runtime_support/provider_option_panel.py +275 -0
  186. package/ciel_runtime_support/provider_option_status.py +192 -0
  187. package/ciel_runtime_support/provider_policy.py +101 -0
  188. package/ciel_runtime_support/provider_query_policy.py +67 -0
  189. package/ciel_runtime_support/provider_readiness.py +112 -0
  190. package/ciel_runtime_support/provider_request_access.py +131 -0
  191. package/ciel_runtime_support/provider_request_builder.py +250 -0
  192. package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
  193. package/ciel_runtime_support/provider_runtime_info.py +113 -0
  194. package/ciel_runtime_support/provider_runtime_modes.py +150 -0
  195. package/ciel_runtime_support/provider_sampling_policy.py +46 -0
  196. package/ciel_runtime_support/provider_status.py +145 -0
  197. package/ciel_runtime_support/provider_timeout_policy.py +184 -0
  198. package/ciel_runtime_support/provider_tool_policy.py +145 -0
  199. package/ciel_runtime_support/providers/__init__.py +65 -0
  200. package/ciel_runtime_support/providers/anthropic.py +160 -0
  201. package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
  202. package/ciel_runtime_support/providers/base.py +232 -0
  203. package/ciel_runtime_support/providers/catalog.py +326 -0
  204. package/ciel_runtime_support/providers/cloud.py +194 -0
  205. package/ciel_runtime_support/providers/constants.py +54 -0
  206. package/ciel_runtime_support/providers/deepseek.py +115 -0
  207. package/ciel_runtime_support/providers/fireworks.py +158 -0
  208. package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
  209. package/ciel_runtime_support/providers/kimi.py +297 -0
  210. package/ciel_runtime_support/providers/lm_studio.py +76 -0
  211. package/ciel_runtime_support/providers/meta.py +257 -0
  212. package/ciel_runtime_support/providers/native.py +194 -0
  213. package/ciel_runtime_support/providers/nim.py +69 -0
  214. package/ciel_runtime_support/providers/nvidia.py +158 -0
  215. package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
  216. package/ciel_runtime_support/providers/ollama.py +181 -0
  217. package/ciel_runtime_support/providers/ollama_context.py +195 -0
  218. package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
  219. package/ciel_runtime_support/providers/opencode.py +220 -0
  220. package/ciel_runtime_support/providers/opencode_go.py +37 -0
  221. package/ciel_runtime_support/providers/openrouter.py +57 -0
  222. package/ciel_runtime_support/providers/vllm.py +65 -0
  223. package/ciel_runtime_support/providers/zai.py +119 -0
  224. package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
  225. package/ciel_runtime_support/rate_limit_policy.py +117 -0
  226. package/ciel_runtime_support/rate_limit_repository.py +154 -0
  227. package/ciel_runtime_support/registry.py +46 -0
  228. package/ciel_runtime_support/request_shortcuts.py +253 -0
  229. package/ciel_runtime_support/request_trace.py +323 -0
  230. package/ciel_runtime_support/response_collection.py +209 -0
  231. package/ciel_runtime_support/router_access.py +238 -0
  232. package/ciel_runtime_support/router_client_lifecycle.py +366 -0
  233. package/ciel_runtime_support/router_health_policy.py +101 -0
  234. package/ciel_runtime_support/router_http.py +513 -0
  235. package/ciel_runtime_support/router_process_lifecycle.py +401 -0
  236. package/ciel_runtime_support/router_rate_limit_service.py +285 -0
  237. package/ciel_runtime_support/router_server_runtime.py +103 -0
  238. package/ciel_runtime_support/router_shortcuts.py +201 -0
  239. package/ciel_runtime_support/routing_fallback.py +73 -0
  240. package/ciel_runtime_support/runtime_activity_repository.py +143 -0
  241. package/ciel_runtime_support/runtime_adapters.py +104 -0
  242. package/ciel_runtime_support/runtime_command_factory.py +73 -0
  243. package/ciel_runtime_support/runtime_compatibility.py +50 -0
  244. package/ciel_runtime_support/runtime_constants.py +178 -0
  245. package/ciel_runtime_support/runtime_launch.py +1602 -0
  246. package/ciel_runtime_support/runtime_llm_options.py +312 -0
  247. package/ciel_runtime_support/runtime_logging.py +161 -0
  248. package/ciel_runtime_support/runtime_paths.py +157 -0
  249. package/ciel_runtime_support/runtime_restart.py +84 -0
  250. package/ciel_runtime_support/runtime_upgrade.py +149 -0
  251. package/ciel_runtime_support/secure_json_repository.py +55 -0
  252. package/ciel_runtime_support/session_import.py +356 -0
  253. package/ciel_runtime_support/settings_repository.py +8 -0
  254. package/ciel_runtime_support/slash_command_assets.py +211 -0
  255. package/ciel_runtime_support/sse_stream.py +57 -0
  256. package/ciel_runtime_support/sse_trace.py +225 -0
  257. package/ciel_runtime_support/statusline_script.py +593 -0
  258. package/ciel_runtime_support/statusline_settings.py +53 -0
  259. package/ciel_runtime_support/stream_chunk_policy.py +18 -0
  260. package/ciel_runtime_support/streaming_anthropic.py +1955 -0
  261. package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
  262. package/ciel_runtime_support/terminal_platform_io.py +127 -0
  263. package/ciel_runtime_support/timeout_profile.py +196 -0
  264. package/ciel_runtime_support/tool_dialects.py +85 -0
  265. package/ciel_runtime_support/tool_exposure_policy.py +63 -0
  266. package/ciel_runtime_support/tool_guard_hooks.py +218 -0
  267. package/ciel_runtime_support/tool_request_projection.py +96 -0
  268. package/ciel_runtime_support/tool_schema.py +483 -0
  269. package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
  270. package/ciel_runtime_support/ui_text.py +266 -0
  271. package/ciel_runtime_support/upstream_error_policy.py +104 -0
  272. package/ciel_runtime_support/upstream_retry.py +419 -0
  273. package/ciel_runtime_support/upstream_stream_io.py +106 -0
  274. package/ciel_runtime_support/usage_events.py +96 -0
  275. package/ciel_runtime_support/visible_stream_filters.py +130 -0
  276. package/ciel_runtime_support/web_endpoints.py +447 -0
  277. package/ciel_runtime_support/web_ui.py +915 -0
  278. package/ciel_runtime_support/web_ui_controller.py +189 -0
  279. package/ciel_runtime_support/windows_console_input.py +137 -0
  280. package/ciel_runtime_support/windows_console_mode.py +112 -0
  281. package/docs/Architecture.md +54 -0
  282. package/docs/Configuration.md +17 -0
  283. package/docs/Module-Map.md +1093 -26
  284. package/docs/Providers.md +46 -1
  285. package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
  286. package/npm-bin/run-ciel-runtime.js +22 -2
  287. package/package.json +9 -2
@@ -0,0 +1,159 @@
1
+ """Bound and deduplicate long-running MCP notification wait tool calls."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from threading import Lock, RLock
6
+ from typing import Any, Callable
7
+
8
+
9
+ WAIT_TOOL_NAMES = frozenset(
10
+ {
11
+ "wait_for_notification",
12
+ "wait_for_notifications",
13
+ "wait_for_message",
14
+ "wait_for_messages",
15
+ "wait_for_event",
16
+ "wait_for_events",
17
+ "wait_for_response",
18
+ "wait_for_responses",
19
+ }
20
+ )
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class McpNotificationWaitPolicy:
25
+ env: Callable[[str], str | None]
26
+
27
+ def timeout_cap_ms(self) -> int:
28
+ return self._bounded_int(
29
+ "CIEL_RUNTIME_MCP_NOTIFICATION_WAIT_TIMEOUT_MS", 1000, 100, 10_000
30
+ )
31
+
32
+ def duplicate_cap_ms(self) -> int:
33
+ return self._bounded_int(
34
+ "CIEL_RUNTIME_MCP_NOTIFICATION_WAIT_DUPLICATE_TIMEOUT_MS", 100, 50, 5000
35
+ )
36
+
37
+ def duplicate_window_seconds(self) -> float:
38
+ raw = self.env("CIEL_RUNTIME_MCP_NOTIFICATION_WAIT_DUPLICATE_WINDOW_SECONDS")
39
+ if raw is None:
40
+ return 90.0
41
+ try:
42
+ value = float(str(raw).strip())
43
+ except (TypeError, ValueError):
44
+ return 90.0
45
+ return max(0.0, min(600.0, value))
46
+
47
+ def _bounded_int(self, name: str, default: int, minimum: int, maximum: int) -> int:
48
+ raw = self.env(name)
49
+ if raw is None:
50
+ return default
51
+ try:
52
+ value = int(float(str(raw).strip()))
53
+ except (TypeError, ValueError):
54
+ return default
55
+ return 0 if value <= 0 else max(minimum, min(maximum, value))
56
+
57
+
58
+ class McpNotificationWaitRepository:
59
+ def __init__(self, recent: dict[str, float], lock: Lock | RLock) -> None:
60
+ self.recent = recent
61
+ self._lock = lock
62
+
63
+ def mark_and_is_duplicate(self, key: str, now: float, window: float) -> bool:
64
+ with self._lock:
65
+ stale = [name for name, seen_at in self.recent.items() if now - seen_at > window]
66
+ for name in stale:
67
+ self.recent.pop(name, None)
68
+ previous = self.recent.get(key)
69
+ duplicate = previous is not None and now - previous <= window
70
+ self.recent[key] = now
71
+ return duplicate
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class McpNotificationWaitPorts:
76
+ lookup_schema: Callable[[str], dict[str, Any] | None]
77
+ now: Callable[[], float]
78
+ log: Callable[[str, str], None]
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class McpNotificationWaitService:
83
+ policy: McpNotificationWaitPolicy
84
+ repository: McpNotificationWaitRepository
85
+ ports: McpNotificationWaitPorts
86
+
87
+ @staticmethod
88
+ def tool_leaf_name(tool_name: str) -> str:
89
+ text = str(tool_name or "").strip()
90
+ return text.rsplit("__", 1)[-1].strip().lower() if "__" in text else text.lower()
91
+
92
+ def is_wait_tool(self, tool_name: str) -> bool:
93
+ text = str(tool_name or "").strip().lower()
94
+ return text.startswith("mcp__") and self.tool_leaf_name(text) in WAIT_TOOL_NAMES
95
+
96
+ def effective_cap_ms(self, tool_name: str) -> tuple[int, bool]:
97
+ cap_ms = self.policy.timeout_cap_ms()
98
+ if cap_ms <= 0:
99
+ return 0, False
100
+ duplicate_cap_ms = self.policy.duplicate_cap_ms()
101
+ window = self.policy.duplicate_window_seconds()
102
+ if duplicate_cap_ms <= 0 or window <= 0:
103
+ return cap_ms, False
104
+ key = str(tool_name or "").strip().lower()
105
+ duplicate = self.repository.mark_and_is_duplicate(key, self.ports.now(), window)
106
+ return (min(cap_ms, duplicate_cap_ms), True) if duplicate else (cap_ms, False)
107
+
108
+ def cap_input(self, tool_name: str, tool_input: dict[str, Any]) -> dict[str, Any]:
109
+ if not self.is_wait_tool(tool_name):
110
+ return tool_input
111
+ cap_ms, duplicate = self.effective_cap_ms(tool_name)
112
+ if cap_ms <= 0:
113
+ return tool_input
114
+ fixed = dict(tool_input) if isinstance(tool_input, dict) else {}
115
+ schema = self.ports.lookup_schema(tool_name) or {}
116
+ properties = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
117
+ changed: list[str] = []
118
+ for key in list(fixed):
119
+ key_lower = str(key).strip().lower()
120
+ if key_lower in {"timeout_ms", "timeoutms", "wait_ms", "waitms", "max_wait_ms", "maxwaitms"}:
121
+ self._set_if_lower(fixed, changed, key, cap_ms)
122
+ elif key_lower in {"timeout", "wait_seconds", "wait_s", "max_wait_seconds"}:
123
+ self._set_if_lower(fixed, changed, key, max(0.1, cap_ms / 1000.0))
124
+ if not changed:
125
+ if "timeout_ms" in properties or "timeout_ms" in fixed or not properties:
126
+ self._set_if_lower(fixed, changed, "timeout_ms", cap_ms)
127
+ elif "timeout" in properties:
128
+ self._set_if_lower(fixed, changed, "timeout", max(0.1, cap_ms / 1000.0))
129
+ if changed:
130
+ duplicate_label = " duplicate=true" if duplicate else ""
131
+ self.ports.log(
132
+ "INFO",
133
+ f"mcp_notification_wait_timeout_capped tool={tool_name}{duplicate_label} {' '.join(changed)}",
134
+ )
135
+ return fixed
136
+
137
+ @staticmethod
138
+ def _set_if_lower(
139
+ target: dict[str, Any], changed: list[str], key: str, value: int | float
140
+ ) -> None:
141
+ old = target.get(key)
142
+ try:
143
+ numeric = float(old)
144
+ except Exception:
145
+ target[key] = int(value) if float(value).is_integer() else value
146
+ changed.append(f"{key}=missing->{value:g}")
147
+ return
148
+ if numeric > float(value):
149
+ target[key] = int(value) if float(value).is_integer() else value
150
+ changed.append(f"{key}={numeric:g}->{value:g}")
151
+
152
+
153
+ __all__ = [
154
+ "McpNotificationWaitPolicy",
155
+ "McpNotificationWaitPorts",
156
+ "McpNotificationWaitRepository",
157
+ "McpNotificationWaitService",
158
+ "WAIT_TOOL_NAMES",
159
+ ]
@@ -0,0 +1,136 @@
1
+ """Pure codecs and capability rules used by MCP channel probes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+
9
+ def probe_strategy(server: dict[str, Any]) -> str:
10
+ """Select JSONL by default, with explicit legacy LSP framing opt-in."""
11
+
12
+ if not isinstance(server, dict):
13
+ return "jsonl"
14
+ mode = str(server.get("ciel_runtime_stdio") or server.get("stdio_mode") or "").strip().lower()
15
+ return "framed" if mode in ("framed", "framed-only", "content-length", "lsp") else "jsonl"
16
+
17
+
18
+ def initialize_payload(client_version: str, protocol_version: str = "2024-11-05") -> dict[str, Any]:
19
+ return {
20
+ "jsonrpc": "2.0",
21
+ "id": 1,
22
+ "method": "initialize",
23
+ "params": {
24
+ "protocolVersion": protocol_version,
25
+ "capabilities": {},
26
+ "clientInfo": {"name": "ciel-runtime-channel-probe", "version": client_version},
27
+ },
28
+ }
29
+
30
+
31
+ def initialize_payload_bytes(client_version: str, protocol_version: str = "2024-11-05") -> bytes:
32
+ return json.dumps(
33
+ initialize_payload(client_version, protocol_version),
34
+ ensure_ascii=False,
35
+ ).encode("utf-8")
36
+
37
+
38
+ def parse_framed_responses(buffer: bytes) -> list[dict[str, Any]]:
39
+ out: list[dict[str, Any]] = []
40
+ index = 0
41
+ while index < len(buffer):
42
+ header_end = buffer.find(b"\r\n\r\n", index)
43
+ if header_end < 0:
44
+ return out
45
+ header = buffer[index:header_end].decode("ascii", errors="replace")
46
+ length = _content_length(header)
47
+ if length is None:
48
+ return out
49
+ body_start = header_end + 4
50
+ body_end = body_start + length
51
+ if len(buffer) < body_end:
52
+ return out
53
+ message = _json_object(buffer[body_start:body_end])
54
+ if message is not None:
55
+ out.append(message)
56
+ index = body_end
57
+ return out
58
+
59
+
60
+ def _content_length(header: str) -> int | None:
61
+ for line in header.split("\r\n"):
62
+ if not line.lower().startswith("content-length:"):
63
+ continue
64
+ try:
65
+ value = int(line.split(":", 1)[1].strip())
66
+ except (IndexError, TypeError, ValueError):
67
+ return None
68
+ return value if value >= 0 else None
69
+ return None
70
+
71
+
72
+ def _json_object(raw: bytes) -> dict[str, Any] | None:
73
+ try:
74
+ message = json.loads(raw.decode("utf-8", errors="replace"))
75
+ except (TypeError, ValueError, json.JSONDecodeError):
76
+ return None
77
+ return message if isinstance(message, dict) else None
78
+
79
+
80
+ def parse_jsonl_responses(buffer: bytes) -> list[dict[str, Any]]:
81
+ return [
82
+ message
83
+ for raw_line in buffer.split(b"\n")
84
+ if raw_line.strip()
85
+ for message in [_json_object(raw_line.strip())]
86
+ if message is not None
87
+ ]
88
+
89
+
90
+ def find_initialize_response(buffer: bytes, framed: bool) -> dict[str, Any] | None:
91
+ messages = parse_framed_responses(buffer) if framed else parse_jsonl_responses(buffer)
92
+ return next((message for message in messages if message.get("id") == 1 and "result" in message), None)
93
+
94
+
95
+ def channel_capability_present(initialize_response: dict[str, Any]) -> bool:
96
+ result = initialize_response.get("result")
97
+ capabilities = result.get("capabilities") if isinstance(result, dict) else None
98
+ experimental = capabilities.get("experimental") if isinstance(capabilities, dict) else None
99
+ value = experimental.get("claude/channel") if isinstance(experimental, dict) else None
100
+ return value is not None and value is not False
101
+
102
+
103
+ def decode_sse_events(buffer: bytearray) -> tuple[list[tuple[str, str]], bytearray]:
104
+ """Drain complete SSE events and return the incomplete byte remainder."""
105
+
106
+ events: list[tuple[str, str]] = []
107
+ text = bytes(buffer).decode("utf-8", errors="replace").replace("\r\n", "\n").replace("\r", "\n")
108
+ while True:
109
+ separator = text.find("\n\n")
110
+ if separator < 0:
111
+ break
112
+ event_text, text = text[:separator], text[separator + 2:]
113
+ event_name = "message"
114
+ data_lines: list[str] = []
115
+ for line in event_text.split("\n"):
116
+ if not line or line.startswith(":"):
117
+ continue
118
+ if line.startswith("event:"):
119
+ event_name = line[len("event:"):].lstrip()
120
+ elif line.startswith("data:"):
121
+ payload = line[len("data:"):]
122
+ data_lines.append(payload[1:] if payload.startswith(" ") else payload)
123
+ events.append((event_name, "\n".join(data_lines)))
124
+ return events, bytearray(text.encode("utf-8"))
125
+
126
+
127
+ __all__ = [
128
+ "channel_capability_present",
129
+ "decode_sse_events",
130
+ "find_initialize_response",
131
+ "initialize_payload",
132
+ "initialize_payload_bytes",
133
+ "parse_framed_responses",
134
+ "parse_jsonl_responses",
135
+ "probe_strategy",
136
+ ]
@@ -0,0 +1,328 @@
1
+ """SSE and Streamable HTTP MCP capability probe transports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ import queue
8
+ import threading
9
+ import time
10
+ from typing import Any, Callable
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class McpProbeCodec:
18
+ initialize_bytes: Callable[..., bytes]
19
+ initialize_dict: Callable[..., dict[str, Any]]
20
+ decode_sse_events: Callable[..., tuple[list[tuple[str, str]], bytearray]]
21
+ capability_present: Callable[..., bool]
22
+ decode_preview: Callable[..., str]
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class McpProbeHttp:
27
+ runtime_headers: Callable[..., dict[str, str]]
28
+ urlopen: Callable[..., Any]
29
+ streamable_post_json: Callable[..., Any]
30
+ delete_streamable_session: Callable[..., Any]
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class McpProbePolicy:
35
+ default_timeout: Callable[[], float]
36
+ stderr_preview_chars: int
37
+ stdout_preview_bytes: int
38
+ sse_open_timeout_seconds: float
39
+ sse_init_post_timeout_seconds: float
40
+ streamable_protocol_version: str
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class McpProbeServices:
45
+ codec: McpProbeCodec
46
+ http: McpProbeHttp
47
+ policy: McpProbePolicy
48
+ log: Callable[[str, str], Any]
49
+
50
+
51
+ def _empty_result(reason: str) -> dict[str, Any]:
52
+ return {
53
+ "capable": False,
54
+ "reason": reason,
55
+ "response_bytes": 0,
56
+ "response_received": False,
57
+ "elapsed_ms": 0,
58
+ "exit_code": None,
59
+ "stderr_bytes": 0,
60
+ "stderr_preview": "",
61
+ "stdout_preview": "",
62
+ }
63
+
64
+
65
+ def probe_sse_mcp_for_channel_capability_detailed(
66
+ server_name: str,
67
+ server: dict[str, Any],
68
+ timeout: float | None = None,
69
+ *,
70
+ services: McpProbeServices,
71
+ ) -> dict[str, Any]:
72
+ started = time.time()
73
+ url = str(server.get("url") or "").strip()
74
+ if not url:
75
+ return _empty_result("no_url")
76
+ codec = services.codec
77
+ http = services.http
78
+ policy = services.policy
79
+ effective_timeout = timeout if timeout is not None else policy.default_timeout()
80
+ open_timeout = min(policy.sse_open_timeout_seconds, effective_timeout)
81
+ request_headers = {
82
+ **http.runtime_headers(server),
83
+ "Accept": "text/event-stream",
84
+ "Cache-Control": "no-cache",
85
+ }
86
+ try:
87
+ get_request = urllib.request.Request(url, headers=request_headers, method="GET")
88
+ sse_response = http.urlopen(get_request, timeout=open_timeout)
89
+ except Exception as exc:
90
+ result = _empty_result(f"sse_open_failed:{type(exc).__name__}")
91
+ result["elapsed_ms"] = int((time.time() - started) * 1000)
92
+ result["stderr_preview"] = str(exc)[:policy.stderr_preview_chars]
93
+ return result
94
+
95
+ chunks: queue.Queue[bytes | None] = queue.Queue()
96
+
97
+ def read_sse() -> None:
98
+ try:
99
+ while True:
100
+ line = sse_response.readline()
101
+ if not line:
102
+ break
103
+ chunks.put(line)
104
+ except Exception as exc:
105
+ services.log(
106
+ "DEBUG",
107
+ f"channel_probe_sse_reader_failed server={server_name} error={type(exc).__name__}: {exc}",
108
+ )
109
+ finally:
110
+ chunks.put(None)
111
+
112
+ threading.Thread(target=read_sse, daemon=True, name=f"channel-probe-sse-{server_name}").start()
113
+ deadline = time.time() + effective_timeout
114
+ sse_buffer = bytearray()
115
+ bytes_seen = 0
116
+ endpoint_url: str | None = None
117
+ init_posted = False
118
+ init_post_error: str | None = None
119
+ capable = False
120
+ response_received = False
121
+ response_data_preview = ""
122
+ post_headers = {"Content-Type": "application/json"}
123
+ for key, value in request_headers.items():
124
+ if key.lower() not in ("accept", "cache-control"):
125
+ post_headers[key] = value
126
+ try:
127
+ while time.time() < deadline:
128
+ wait = min(0.2, max(0.001, deadline - time.time()))
129
+ try:
130
+ chunk = chunks.get(timeout=wait)
131
+ except queue.Empty:
132
+ continue
133
+ if chunk is None:
134
+ break
135
+ sse_buffer.extend(chunk)
136
+ bytes_seen += len(chunk)
137
+ events, sse_buffer = codec.decode_sse_events(sse_buffer)
138
+ for event_name, data_text in events:
139
+ if not init_posted and event_name == "endpoint":
140
+ target = data_text.strip()
141
+ if not target:
142
+ continue
143
+ endpoint_url = urllib.parse.urljoin(url, target)
144
+ try:
145
+ post_request = urllib.request.Request(
146
+ endpoint_url,
147
+ data=codec.initialize_bytes(),
148
+ headers=post_headers,
149
+ method="POST",
150
+ )
151
+ with http.urlopen(
152
+ post_request,
153
+ timeout=min(policy.sse_init_post_timeout_seconds, max(1.0, deadline - time.time())),
154
+ ) as post_response:
155
+ post_response.read()
156
+ init_posted = True
157
+ except Exception as exc:
158
+ init_post_error = f"{type(exc).__name__}: {exc}"
159
+ break
160
+ continue
161
+ if not init_posted or not data_text:
162
+ continue
163
+ try:
164
+ message = json.loads(data_text)
165
+ except (TypeError, ValueError):
166
+ continue
167
+ if not isinstance(message, dict):
168
+ continue
169
+ if message.get("id") == 1 and "result" in message:
170
+ response_received = True
171
+ capable = codec.capability_present(message)
172
+ response_data_preview = codec.decode_preview(
173
+ data_text.encode("utf-8"), policy.stdout_preview_bytes
174
+ )
175
+ break
176
+ if response_received or init_post_error:
177
+ break
178
+ finally:
179
+ try:
180
+ sse_response.close()
181
+ except Exception as exc:
182
+ services.log(
183
+ "DEBUG",
184
+ f"channel_probe_sse_close_failed server={server_name} error={type(exc).__name__}: {exc}",
185
+ )
186
+ elapsed_ms = int((time.time() - started) * 1000)
187
+ if capable:
188
+ reason = "capable"
189
+ elif response_received:
190
+ reason = "no_experimental_claude_channel"
191
+ elif init_post_error:
192
+ reason = f"sse_init_post_failed:{init_post_error.split(':', 1)[0]}"
193
+ elif init_posted:
194
+ reason = "timeout_waiting_for_initialize_response"
195
+ elif endpoint_url:
196
+ reason = "timeout_after_endpoint_event"
197
+ else:
198
+ reason = "timeout_no_endpoint_event"
199
+ stderr_preview = init_post_error[:policy.stderr_preview_chars] if init_post_error else ""
200
+ stdout_preview = response_data_preview if response_data_preview and not capable else ""
201
+ services.log(
202
+ "INFO",
203
+ "channel_probe_result server=%s channel_capable=%s reason=%s transport=sse url=%s bytes=%d elapsed_ms=%d timeout_s=%.1f"
204
+ % (server_name, capable, reason, url, bytes_seen, elapsed_ms, effective_timeout),
205
+ )
206
+ if stderr_preview:
207
+ services.log("INFO", f"channel_probe_sse_error server={server_name} preview={stderr_preview!r}")
208
+ return {
209
+ "capable": capable,
210
+ "reason": reason,
211
+ "response_bytes": bytes_seen,
212
+ "response_received": response_received,
213
+ "elapsed_ms": elapsed_ms,
214
+ "exit_code": None,
215
+ "stderr_bytes": len(stderr_preview),
216
+ "stderr_preview": stderr_preview,
217
+ "stdout_preview": stdout_preview,
218
+ }
219
+
220
+
221
+ def probe_streamable_http_mcp_for_channel_capability_detailed(
222
+ server_name: str,
223
+ server: dict[str, Any],
224
+ timeout: float | None = None,
225
+ *,
226
+ services: McpProbeServices,
227
+ ) -> dict[str, Any]:
228
+ started = time.time()
229
+ url = str(server.get("url") or server.get("endpoint") or "").strip()
230
+ if not url:
231
+ return _empty_result("no_url")
232
+ codec = services.codec
233
+ http = services.http
234
+ policy = services.policy
235
+ effective_timeout = timeout if timeout is not None else policy.default_timeout()
236
+ protocol_version = str(
237
+ server.get("protocolVersion")
238
+ or server.get("protocol_version")
239
+ or policy.streamable_protocol_version
240
+ )
241
+ headers = http.runtime_headers(server)
242
+ bytes_seen = 0
243
+ response_received = False
244
+ capable = False
245
+ reason = ""
246
+ stderr_preview = ""
247
+ stdout_preview = ""
248
+ initialized_failed = False
249
+ session_id: str | None = None
250
+ try:
251
+ response, session_id = http.streamable_post_json(
252
+ url,
253
+ headers,
254
+ codec.initialize_dict(protocol_version),
255
+ max(1.0, min(120.0, effective_timeout)),
256
+ protocol_version,
257
+ )
258
+ preview_source = json.dumps(response, ensure_ascii=False) if isinstance(response, (dict, list)) else str(response or "")
259
+ bytes_seen = len(preview_source.encode("utf-8", errors="replace"))
260
+ if isinstance(response, dict) and response.get("id") == 1 and "result" in response:
261
+ response_received = True
262
+ capable = codec.capability_present(response)
263
+ if not capable:
264
+ stdout_preview = codec.decode_preview(preview_source.encode("utf-8"), policy.stdout_preview_bytes)
265
+ else:
266
+ stdout_preview = codec.decode_preview(preview_source.encode("utf-8"), policy.stdout_preview_bytes)
267
+ if response_received:
268
+ try:
269
+ http.streamable_post_json(
270
+ url,
271
+ headers,
272
+ {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
273
+ max(1.0, min(120.0, effective_timeout)),
274
+ protocol_version,
275
+ session_id,
276
+ )
277
+ except Exception as exc:
278
+ initialized_failed = True
279
+ capable = False
280
+ stderr_preview = f"{type(exc).__name__}: {exc}"[:policy.stderr_preview_chars]
281
+ reason = "streamable_http_initialized_post_failed" if initialized_failed else (
282
+ "capable" if capable else ("no_experimental_claude_channel" if response_received else "no_initialize_response")
283
+ )
284
+ except urllib.error.HTTPError as exc:
285
+ reason = "streamable_http_405_fallback_sse" if exc.code == 405 else f"streamable_http_open_failed:HTTPError:{exc.code}"
286
+ try:
287
+ data = exc.read()
288
+ except Exception as read_exc:
289
+ services.log(
290
+ "DEBUG",
291
+ f"channel_probe_http_error_read_failed server={server_name} error={type(read_exc).__name__}: {read_exc}",
292
+ )
293
+ data = b""
294
+ bytes_seen = len(data)
295
+ stderr_preview = (data.decode("utf-8", errors="replace") or str(exc))[:policy.stderr_preview_chars]
296
+ except Exception as exc:
297
+ reason = f"streamable_http_open_failed:{type(exc).__name__}"
298
+ stderr_preview = str(exc)[:policy.stderr_preview_chars]
299
+ finally:
300
+ if session_id:
301
+ http.delete_streamable_session(
302
+ f"probe-{server_name}",
303
+ url,
304
+ headers,
305
+ protocol_version,
306
+ session_id,
307
+ "channel_probe_cleanup",
308
+ timeout=min(10.0, max(1.0, effective_timeout)),
309
+ )
310
+ elapsed_ms = int((time.time() - started) * 1000)
311
+ services.log(
312
+ "INFO",
313
+ "channel_probe_result server=%s channel_capable=%s reason=%s transport=streamable-http url=%s bytes=%d elapsed_ms=%d timeout_s=%.1f"
314
+ % (server_name, capable, reason, url, bytes_seen, elapsed_ms, effective_timeout),
315
+ )
316
+ if stderr_preview:
317
+ services.log("INFO", f"channel_probe_streamable_http_error server={server_name} preview={stderr_preview!r}")
318
+ return {
319
+ "capable": capable,
320
+ "reason": reason,
321
+ "response_bytes": bytes_seen,
322
+ "response_received": response_received,
323
+ "elapsed_ms": elapsed_ms,
324
+ "exit_code": None,
325
+ "stderr_bytes": len(stderr_preview),
326
+ "stderr_preview": stderr_preview,
327
+ "stdout_preview": stdout_preview,
328
+ }