@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,239 @@
1
+ """HTTP/SSE controller for the built-in Channel MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import urllib.parse
7
+ from dataclasses import dataclass
8
+ from http.server import BaseHTTPRequestHandler
9
+ from threading import Condition, Lock
10
+ from typing import Any, Callable
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class ChannelMcpSessionStore:
15
+ states: dict[str, dict[str, Any]]
16
+ lock: Lock
17
+
18
+ def create(self, session: str, last_id: int) -> None:
19
+ with self.lock:
20
+ self.states[session] = {
21
+ "created_at": time.time(),
22
+ "last_id": last_id,
23
+ "initialized": False,
24
+ "outbox": [],
25
+ }
26
+
27
+ def stream_state(self, session: str) -> tuple[int, bool] | None:
28
+ with self.lock:
29
+ state = self.states.get(session)
30
+ if not state:
31
+ return None
32
+ return int(state.get("last_id") or 0), bool(state.get("initialized"))
33
+
34
+ def set_last_id(self, session: str, last_id: int) -> None:
35
+ with self.lock:
36
+ if state := self.states.get(session):
37
+ state["last_id"] = last_id
38
+
39
+ def touch(self, session: str) -> None:
40
+ with self.lock:
41
+ if session and session in self.states:
42
+ self.states[session]["last_seen_at"] = time.time()
43
+
44
+ def initialize(self, session: str) -> None:
45
+ with self.lock:
46
+ if session and session in self.states:
47
+ self.states[session]["initialized"] = True
48
+
49
+ def remove(self, session: str) -> None:
50
+ with self.lock:
51
+ self.states.pop(session, None)
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class ChannelMcpStreamServices:
56
+ new_session_id: Callable[[], str]
57
+ start_last_id: Callable[[BaseHTTPRequestHandler], int]
58
+ send_headers: Callable[[BaseHTTPRequestHandler], None]
59
+ write_event: Callable[..., None]
60
+ take_outbox: Callable[[str], list[dict[str, Any]]]
61
+ read_messages: Callable[[int, str | None, str | None, int], list[dict[str, Any]]]
62
+ project_notifications: Callable[[list[dict[str, Any]], str], tuple[int, list[tuple[int, dict[str, Any]]]]]
63
+ update_cursor: Callable[[int], None]
64
+ condition: Condition
65
+ log: Callable[[str, str], None]
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class ChannelMcpRpcServices:
70
+ initialize_response: Callable[[Any, str], dict[str, Any]]
71
+ tool_schemas: Callable[[], list[dict[str, Any]]]
72
+ tool_call_response: Callable[[Any, dict[str, Any]], dict[str, Any]]
73
+ enqueue: Callable[[str, dict[str, Any]], bool]
74
+ write_json: Callable[..., None]
75
+ write_accepted: Callable[[BaseHTTPRequestHandler], None]
76
+ log: Callable[[str, str], None]
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class ChannelMcpHttpController:
81
+ store: ChannelMcpSessionStore
82
+ stream: ChannelMcpStreamServices
83
+ rpc: ChannelMcpRpcServices
84
+
85
+ def get(self, handler: BaseHTTPRequestHandler, path: str) -> bool:
86
+ if path == "/ca/mcp/health":
87
+ self.rpc.write_json(
88
+ handler,
89
+ {
90
+ "ok": True,
91
+ "name": "ciel-runtime-router",
92
+ "streamable_http": "/ca/mcp",
93
+ "sse": "/ca/mcp/sse",
94
+ },
95
+ )
96
+ return True
97
+ if path != "/ca/mcp/sse":
98
+ return False
99
+ session = self.stream.new_session_id()
100
+ last_id = self.stream.start_last_id(handler)
101
+ self.store.create(session, last_id)
102
+ self.stream.log("INFO", f"channel_mcp_session_started session={session} last_id={last_id}")
103
+ started_at = time.time()
104
+ close_reason = "finished"
105
+ self.stream.send_headers(handler)
106
+ self.stream.write_event(
107
+ handler,
108
+ "endpoint",
109
+ f"/ca/mcp/messages?sessionId={urllib.parse.quote(session)}",
110
+ )
111
+ try:
112
+ while True:
113
+ current = self.store.stream_state(session)
114
+ if current is None:
115
+ close_reason = "session_missing"
116
+ return True
117
+ last_id, initialized = current
118
+ if outbox := self.stream.take_outbox(session):
119
+ for payload in outbox:
120
+ self.stream.write_event(handler, "message", payload)
121
+ self.stream.log("INFO", f"channel_mcp_rpc_flushed session={session} count={len(outbox)}")
122
+ continue
123
+ if not initialized:
124
+ handler.wfile.write(b": waiting-for-initialize\n\n")
125
+ handler.wfile.flush()
126
+ with self.stream.condition:
127
+ self.stream.condition.wait(timeout=1.0)
128
+ continue
129
+ messages = self.stream.read_messages(last_id, None, None, 100)
130
+ if messages:
131
+ delivered_id, events = self.stream.project_notifications(messages, session)
132
+ last_id = max(last_id, delivered_id)
133
+ for event_id, notification in events:
134
+ self.stream.write_event(handler, "message", notification, event_id)
135
+ self.stream.log(
136
+ "INFO",
137
+ f"channel_mcp_notification_written session={session} message_id={event_id}",
138
+ )
139
+ self.stream.update_cursor(last_id)
140
+ self.store.set_last_id(session, last_id)
141
+ continue
142
+ handler.wfile.write(b": keepalive\n\n")
143
+ handler.wfile.flush()
144
+ with self.stream.condition:
145
+ self.stream.condition.wait(timeout=5.0)
146
+ except (BrokenPipeError, ConnectionError, ConnectionResetError) as error:
147
+ close_reason = type(error).__name__
148
+ return True
149
+ except Exception as error:
150
+ close_reason = type(error).__name__
151
+ self.stream.log(
152
+ "ERROR",
153
+ f"channel_mcp_session_failed session={session} error={type(error).__name__}: {error}",
154
+ )
155
+ return True
156
+ finally:
157
+ age = time.time() - started_at
158
+ self.stream.log(
159
+ "INFO",
160
+ f"channel_mcp_session_closed session={session} reason={close_reason} age={age:.1f}s",
161
+ )
162
+ self.store.remove(session)
163
+
164
+ def post(
165
+ self,
166
+ handler: BaseHTTPRequestHandler,
167
+ path: str,
168
+ body: dict[str, Any],
169
+ ) -> bool:
170
+ if path == "/ca/mcp":
171
+ response = self._rpc_response(body)
172
+ if response is None:
173
+ self.rpc.write_accepted(handler)
174
+ else:
175
+ self.rpc.write_json(handler, response)
176
+ self.rpc.log(
177
+ "INFO",
178
+ "channel_mcp_streamable_http method=%s request_id=%s"
179
+ % (str(body.get("method") or ""), body.get("id")),
180
+ )
181
+ return True
182
+ if path != "/ca/mcp/messages":
183
+ return False
184
+ query = urllib.parse.parse_qs(
185
+ urllib.parse.urlparse(handler.path).query,
186
+ keep_blank_values=True,
187
+ )
188
+ session = (query.get("sessionId") or query.get("session") or [""])[0]
189
+ self.store.touch(session)
190
+ method = str(body.get("method") or "")
191
+ request_id = body.get("id")
192
+ if method == "initialize":
193
+ self.store.initialize(session)
194
+ response = self._rpc_response(body)
195
+ if method == "initialize":
196
+ params = body.get("params") if isinstance(body.get("params"), dict) else {}
197
+ protocol = str(params.get("protocolVersion") or "2024-11-05")
198
+ self.rpc.log("INFO", f"channel_mcp_initialized session={session or '-'} protocol={protocol}")
199
+ if response is not None:
200
+ if not self.rpc.enqueue(session, response):
201
+ self.rpc.log("WARN", f"channel_mcp_rpc_enqueue_failed session={session or '-'} method={method}")
202
+ self.rpc.write_json(
203
+ handler,
204
+ {
205
+ "jsonrpc": "2.0",
206
+ "id": request_id,
207
+ "error": {"code": -32000, "message": "MCP SSE session is not connected"},
208
+ },
209
+ 404,
210
+ )
211
+ return True
212
+ self.rpc.log(
213
+ "INFO",
214
+ f"channel_mcp_rpc_queued session={session or '-'} method={method} request_id={request_id}",
215
+ )
216
+ self.rpc.write_accepted(handler)
217
+ return True
218
+
219
+ def _rpc_response(self, body: dict[str, Any]) -> dict[str, Any] | None:
220
+ method = str(body.get("method") or "")
221
+ request_id = body.get("id")
222
+ if method == "initialize":
223
+ params = body.get("params") if isinstance(body.get("params"), dict) else {}
224
+ protocol = str(params.get("protocolVersion") or "2024-11-05")
225
+ return self.rpc.initialize_response(request_id, protocol)
226
+ if method == "tools/list":
227
+ return {
228
+ "jsonrpc": "2.0",
229
+ "id": request_id,
230
+ "result": {"tools": self.rpc.tool_schemas()},
231
+ }
232
+ if method == "tools/call":
233
+ params = body.get("params") if isinstance(body.get("params"), dict) else {}
234
+ return self.rpc.tool_call_response(request_id, params)
235
+ if method == "ping":
236
+ return {"jsonrpc": "2.0", "id": request_id, "result": {}}
237
+ if request_id is not None:
238
+ return {"jsonrpc": "2.0", "id": request_id, "result": {}}
239
+ return None
@@ -0,0 +1,148 @@
1
+ """Ownership repository and lifecycle for MCP channel notification streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable, Iterable
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ class ChannelProxyOwnershipRepository:
13
+ def __init__(
14
+ self,
15
+ proxy_config: Path,
16
+ disable_notifications: Callable[[dict[str, Any]], bool],
17
+ log: Callable[[str, str], None],
18
+ ) -> None:
19
+ self.proxy_config = proxy_config
20
+ self.disable_notifications = disable_notifications
21
+ self.log = log
22
+
23
+ def owned_names(self) -> set[str]:
24
+ try:
25
+ data = json.loads(self.proxy_config.read_text(encoding="utf-8"))
26
+ except FileNotFoundError:
27
+ return set()
28
+ except (OSError, UnicodeError, json.JSONDecodeError, TypeError) as exc:
29
+ self.log(
30
+ "WARN",
31
+ f"proxy_owned_channel_config_read_failed path={self.proxy_config} "
32
+ f"error={type(exc).__name__}: {exc}",
33
+ )
34
+ return set()
35
+ servers = data.get("mcpServers") if isinstance(data, dict) else None
36
+ if not isinstance(servers, dict):
37
+ return set()
38
+ owned: set[str] = set()
39
+ for name, entry in servers.items():
40
+ if not isinstance(entry, dict) or not isinstance(entry.get("args"), list):
41
+ continue
42
+ arguments = [str(argument) for argument in entry["args"]]
43
+ if "mcp-proxy" not in arguments or "--server-name" not in arguments:
44
+ continue
45
+ try:
46
+ wrapped_name = arguments[arguments.index("--server-name") + 1].strip()
47
+ except IndexError:
48
+ wrapped_name = str(name).strip()
49
+ if self.server_config_disables_notifications(arguments):
50
+ continue
51
+ if wrapped_name:
52
+ owned.add(wrapped_name)
53
+ return owned
54
+
55
+ def server_config_disables_notifications(self, arguments: list[str]) -> bool:
56
+ try:
57
+ path = Path(arguments[arguments.index("--server-config") + 1])
58
+ except (ValueError, IndexError):
59
+ return False
60
+ try:
61
+ server = json.loads(path.read_text(encoding="utf-8"))
62
+ except (OSError, UnicodeError, json.JSONDecodeError, TypeError) as exc:
63
+ self.log(
64
+ "WARN",
65
+ f"proxy_server_config_read_failed path={path} "
66
+ f"error={type(exc).__name__}: {exc}",
67
+ )
68
+ return False
69
+ return (
70
+ self.disable_notifications(server) if isinstance(server, dict) else False
71
+ )
72
+
73
+
74
+ @dataclass(frozen=True, slots=True)
75
+ class ChannelRouterLifecyclePorts:
76
+ delivery_enabled: Callable[..., bool]
77
+ launch_specs: Callable[[dict[str, Any], list[str]], list[str]]
78
+ server_names: Callable[[Iterable[str]], list[str]]
79
+ owned_names: Callable[[], set[str]]
80
+ public_name: Callable[[str], str]
81
+ ensure_probe: Callable[..., bool]
82
+ source_paths: Callable[[Iterable[str]], list[Path]]
83
+ auto_start: Callable[..., list[dict[str, Any]]]
84
+ log: Callable[[str, str], None]
85
+
86
+
87
+ class ChannelRouterLifecycle:
88
+ def __init__(
89
+ self,
90
+ native_router_names: frozenset[str],
91
+ ports: ChannelRouterLifecyclePorts,
92
+ ) -> None:
93
+ self.native_router_names = native_router_names
94
+ self.ports = ports
95
+
96
+ def managed_names(self, config: dict[str, Any]) -> list[str]:
97
+ names = self.ports.server_names(self.ports.launch_specs(config, []))
98
+ names = [name for name in names if name.lower() not in self.native_router_names]
99
+ owned = self.ports.owned_names()
100
+ if not owned:
101
+ return names
102
+ owned_public = {self.ports.public_name(name) for name in owned}
103
+ kept = [
104
+ name for name in names if self.ports.public_name(name) not in owned_public
105
+ ]
106
+ if len(kept) != len(names):
107
+ dropped = sorted(set(names) - set(kept))
108
+ self.ports.log(
109
+ "INFO",
110
+ "router_channel_sse_skipped_proxy_owned "
111
+ f"servers={','.join(dropped) or '-'}",
112
+ )
113
+ return kept
114
+
115
+ def start(self, config: dict[str, Any]) -> list[dict[str, Any]]:
116
+ if not self.ports.delivery_enabled(True, [], config):
117
+ return []
118
+ names = self.managed_names(config)
119
+ if not names:
120
+ self.ports.log(
121
+ "INFO", "router_channel_sse_skipped reason=no_external_channel_specs"
122
+ )
123
+ return []
124
+ source_paths: list[Path] = []
125
+ try:
126
+ self.ports.ensure_probe(config, [])
127
+ source_paths = self.ports.source_paths(
128
+ [f"server:{name}" for name in names]
129
+ )
130
+ except Exception as exc:
131
+ self.ports.log(
132
+ "WARN",
133
+ f"router_channel_probe_cache_failed "
134
+ f"error={type(exc).__name__}: {exc}",
135
+ )
136
+ started = self.ports.auto_start(
137
+ [],
138
+ extra_config_paths=source_paths,
139
+ allowed_server_names=list(names),
140
+ )
141
+ self.ports.log(
142
+ "INFO",
143
+ f"router_channel_sse_started count={len(started)} servers="
144
+ + (
145
+ ",".join(str(item.get("name") or "") for item in started) or "-"
146
+ ),
147
+ )
148
+ return started
@@ -0,0 +1,240 @@
1
+ """Built-in channel MCP tool catalog and application service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ChannelMcpToolServices:
12
+ queue_compact: Callable[[str, str], dict[str, Any]]
13
+ append_message: Callable[[dict[str, Any]], dict[str, Any]]
14
+ store_file_path: Callable[[Any, str | None, str | None], dict[str, Any]]
15
+ store_file_upload: Callable[[dict[str, Any]], dict[str, Any]]
16
+ file_message_text: Callable[[str, list[dict[str, Any]]], str]
17
+ handle_llm_options: Callable[[str, str], tuple[list[str], bool]]
18
+
19
+
20
+ def channel_mcp_tool_schemas() -> list[dict[str, Any]]:
21
+ return [
22
+ {
23
+ "name": "compact_session",
24
+ "description": (
25
+ "Queue Claude Code's /compact slash command for the active Ciel Runtime-launched session. "
26
+ "Use this when the conversation context is too large and the session should compact itself."
27
+ ),
28
+ "inputSchema": {
29
+ "type": "object",
30
+ "properties": {
31
+ "reason": {
32
+ "type": "string",
33
+ "description": "Optional short reason shown in Ciel Runtime logs.",
34
+ },
35
+ },
36
+ },
37
+ },
38
+ {
39
+ "name": "send_message",
40
+ "description": (
41
+ "Send a reply or status message to a Ciel Runtime channel. "
42
+ "Use this to answer messages delivered through the Ciel Runtime channel inbox, "
43
+ "including /ca/web/chat browser sessions."
44
+ ),
45
+ "inputSchema": {
46
+ "type": "object",
47
+ "properties": {
48
+ "channel": {"type": "string", "description": "Destination channel id from the incoming message."},
49
+ "message": {"type": "string", "description": "Message body to send."},
50
+ "recipients": {
51
+ "description": "Recipient id, 'all', or an array of recipients. Use 'web' for /ca/web/chat replies."
52
+ },
53
+ "thread_id": {"type": "string", "description": "Thread/conversation id to continue."},
54
+ "parent_id": {"description": "Optional parent message id."},
55
+ "delivery": {
56
+ "type": "array",
57
+ "items": {"type": "string"},
58
+ "description": "Delivery targets. Use ['web'] for browser-only replies.",
59
+ },
60
+ "kind": {"type": "string", "description": "Optional message kind, for example 'reply' or 'status'."},
61
+ },
62
+ "required": ["channel", "message"],
63
+ },
64
+ },
65
+ {
66
+ "name": "send_file",
67
+ "description": (
68
+ "Send a file attachment to a Ciel Runtime channel. "
69
+ "Use this to return files to /ca/web/chat browser sessions. "
70
+ "Provide either path for an existing local file, or content with encoding='text' or encoding='base64'."
71
+ ),
72
+ "inputSchema": {
73
+ "type": "object",
74
+ "properties": {
75
+ "channel": {"type": "string", "description": "Destination channel id from the incoming message."},
76
+ "path": {"type": "string", "description": "Optional local file path to attach."},
77
+ "content": {
78
+ "type": "string",
79
+ "description": "Optional inline file content when path is not used.",
80
+ },
81
+ "encoding": {"type": "string", "description": "Inline content encoding: text or base64."},
82
+ "name": {
83
+ "type": "string",
84
+ "description": "Display filename. Defaults to the source path basename or file.txt.",
85
+ },
86
+ "content_type": {"type": "string", "description": "Optional MIME type."},
87
+ "message": {"type": "string", "description": "Optional message body to show with the file link."},
88
+ "recipients": {
89
+ "description": "Recipient id, 'all', or an array of recipients. Use 'web' for /ca/web/chat replies."
90
+ },
91
+ "thread_id": {"type": "string", "description": "Thread/conversation id to continue."},
92
+ "parent_id": {"description": "Optional parent message id."},
93
+ "delivery": {
94
+ "type": "array",
95
+ "items": {"type": "string"},
96
+ "description": "Delivery targets. Use ['web'] for browser-only replies.",
97
+ },
98
+ },
99
+ "required": ["channel"],
100
+ },
101
+ },
102
+ {
103
+ "name": "llm_options",
104
+ "description": (
105
+ "Show, apply, or restore ciel-runtime live LLM option presets for the current routed session. "
106
+ "Use action='list' to show keyboard-selectable slash commands, action='apply' with preset, "
107
+ "or action='restore' to return to the captured original options."
108
+ ),
109
+ "inputSchema": {
110
+ "type": "object",
111
+ "properties": {
112
+ "action": {"type": "string", "description": "One of status, list, apply, or restore."},
113
+ "preset": {
114
+ "type": "string",
115
+ "description": (
116
+ "Preset id or alias when action is apply, for example balanced, long-context-256k, "
117
+ "long-context-300k, long-context-512k, or million-context-1m."
118
+ ),
119
+ },
120
+ },
121
+ },
122
+ },
123
+ ]
124
+
125
+
126
+ def channel_mcp_tool_response(request_id: Any, text: str, is_error: bool = False) -> dict[str, Any]:
127
+ return {
128
+ "jsonrpc": "2.0",
129
+ "id": request_id,
130
+ "result": {"content": [{"type": "text", "text": text}], "isError": bool(is_error)},
131
+ }
132
+
133
+
134
+ def dispatch_channel_mcp_tool(
135
+ request_id: Any,
136
+ params: dict[str, Any],
137
+ services: ChannelMcpToolServices,
138
+ ) -> dict[str, Any]:
139
+ name = str(params.get("name") or "")
140
+ args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
141
+ if name == "compact_session":
142
+ request = services.queue_compact("ciel-runtime-router-tool", str(args.get("reason") or ""))
143
+ return _json_response(
144
+ request_id,
145
+ {
146
+ "ok": True,
147
+ "queued": True,
148
+ "command": request.get("command"),
149
+ "request_id": request.get("id"),
150
+ "expires_at": request.get("expires_at"),
151
+ },
152
+ )
153
+ if name == "send_message":
154
+ return _send_message(request_id, args, services)
155
+ if name == "send_file":
156
+ return _send_file(request_id, args, services)
157
+ if name == "llm_options":
158
+ lines, changed = services.handle_llm_options(
159
+ str(args.get("action") or "status"),
160
+ str(args.get("preset") or ""),
161
+ )
162
+ return _json_response(request_id, {"ok": True, "changed": changed, "lines": lines})
163
+ return channel_mcp_tool_response(request_id, f"Unknown ciel-runtime-router tool: {name}", True)
164
+
165
+
166
+ def _send_message(
167
+ request_id: Any,
168
+ args: dict[str, Any],
169
+ services: ChannelMcpToolServices,
170
+ ) -> dict[str, Any]:
171
+ channel = str(args.get("channel") or "").strip()
172
+ message = str(args.get("message") or args.get("text") or "").strip()
173
+ if not channel or not message:
174
+ return channel_mcp_tool_response(request_id, "send_message requires channel and message.", True)
175
+ saved = services.append_message(_message_payload(args, channel, message, "reply"))
176
+ return _json_response(request_id, {"ok": True, "message": saved})
177
+
178
+
179
+ def _send_file(
180
+ request_id: Any,
181
+ args: dict[str, Any],
182
+ services: ChannelMcpToolServices,
183
+ ) -> dict[str, Any]:
184
+ channel = str(args.get("channel") or "").strip()
185
+ if not channel:
186
+ return channel_mcp_tool_response(request_id, "send_file requires channel.", True)
187
+ try:
188
+ upload = _store_file(args, services)
189
+ except (FileNotFoundError, OverflowError, ValueError) as exc:
190
+ return channel_mcp_tool_response(request_id, str(exc), True)
191
+ uploads = [upload]
192
+ message = services.file_message_text(str(args.get("message") or ""), uploads)
193
+ payload = _message_payload(args, channel, message, "file")
194
+ payload["meta"] = {"attachments": uploads, **payload["meta"]}
195
+ saved = services.append_message(payload)
196
+ return _json_response(request_id, {"ok": True, "file": upload, "message": saved})
197
+
198
+
199
+ def _store_file(args: dict[str, Any], services: ChannelMcpToolServices) -> dict[str, Any]:
200
+ if args.get("path"):
201
+ return services.store_file_path(
202
+ args.get("path"),
203
+ str(args.get("name") or "").strip() or None,
204
+ str(args.get("content_type") or args.get("mime_type") or "").strip() or None,
205
+ )
206
+ return services.store_file_upload(
207
+ {
208
+ "name": str(args.get("name") or "file.txt"),
209
+ "encoding": str(args.get("encoding") or "text"),
210
+ "content": args.get("content", ""),
211
+ "content_type": str(args.get("content_type") or args.get("mime_type") or "text/plain"),
212
+ }
213
+ )
214
+
215
+
216
+ def _message_payload(
217
+ args: dict[str, Any],
218
+ channel: str,
219
+ message: str,
220
+ default_kind: str,
221
+ ) -> dict[str, Any]:
222
+ meta = args.get("meta") if isinstance(args.get("meta"), dict) else {}
223
+ return {
224
+ "channel": channel,
225
+ "sender_id": args.get("sender_id") or "claude-code",
226
+ "recipients": args.get("recipients", args.get("recipient_id", "web")),
227
+ "thread_id": args.get("thread_id"),
228
+ "parent_id": args.get("parent_id"),
229
+ "kind": args.get("kind") or default_kind,
230
+ "message": message,
231
+ "delivery": args.get("delivery", ["web"]),
232
+ "meta": {"source": "ciel-runtime-router-tool", **meta},
233
+ }
234
+
235
+
236
+ def _json_response(request_id: Any, payload: dict[str, Any]) -> dict[str, Any]:
237
+ return channel_mcp_tool_response(
238
+ request_id,
239
+ json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
240
+ )