@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,281 @@
1
+ """Application service for persisted Channel configuration and CLI imports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ChannelConfigPorts:
12
+ load: Callable[[], dict[str, Any]]
13
+ save: Callable[[dict[str, Any]], None]
14
+ invalidate: Callable[[], None]
15
+ dedupe: Callable[[Iterable[str]], list[str]]
16
+ log: Callable[[str, str], None]
17
+ environment: Mapping[str, str]
18
+
19
+
20
+ class ChannelConfigService:
21
+ def __init__(
22
+ self,
23
+ builtin_spec: str,
24
+ ports: ChannelConfigPorts,
25
+ ) -> None:
26
+ self.builtin_spec = builtin_spec
27
+ self.ports = ports
28
+
29
+ @staticmethod
30
+ def is_tagged(spec: str) -> bool:
31
+ return spec.startswith("plugin:") or spec.startswith("server:")
32
+
33
+ def configured_specs(self, config: dict[str, Any]) -> list[str]:
34
+ raw = config.setdefault("claude_code", {}).get("channels", [])
35
+ if isinstance(raw, str):
36
+ items = [raw]
37
+ elif isinstance(raw, list):
38
+ items = raw
39
+ else:
40
+ items = []
41
+ return self.ports.dedupe(
42
+ [self.builtin_spec, *(str(item).strip() for item in items)]
43
+ )
44
+
45
+ def parse_passthrough(self, passthrough: list[str]) -> list[str]:
46
+ specs: list[str] = []
47
+ index = 0
48
+ options = ("--channels", "--dangerously-load-development-channels")
49
+ while index < len(passthrough):
50
+ argument = passthrough[index]
51
+ if argument in options:
52
+ index += 1
53
+ while index < len(passthrough) and self.is_tagged(passthrough[index]):
54
+ specs.append(passthrough[index])
55
+ index += 1
56
+ continue
57
+ if any(argument.startswith(option + "=") for option in options):
58
+ value = argument.split("=", 1)[1].strip()
59
+ if value and self.is_tagged(value):
60
+ specs.append(value)
61
+ index += 1
62
+ return self.ports.dedupe(specs)
63
+
64
+ def normalize_passthrough(
65
+ self,
66
+ passthrough: list[str],
67
+ ) -> list[str]:
68
+ normalized: list[str] = []
69
+ index = 0
70
+ while index < len(passthrough):
71
+ argument = passthrough[index]
72
+ if argument == "--channels":
73
+ normalized.append(
74
+ "--dangerously-load-development-channels"
75
+ )
76
+ index += 1
77
+ while (
78
+ index < len(passthrough)
79
+ and self.is_tagged(passthrough[index])
80
+ ):
81
+ normalized.append(passthrough[index])
82
+ index += 1
83
+ continue
84
+ if argument.startswith("--channels="):
85
+ value = argument.split("=", 1)[1].strip()
86
+ normalized.append(
87
+ "--dangerously-load-development-channels"
88
+ )
89
+ if value:
90
+ normalized.append(value)
91
+ index += 1
92
+ continue
93
+ normalized.append(argument)
94
+ index += 1
95
+ return normalized
96
+
97
+ def auto_import(self, passthrough: list[str]) -> list[str]:
98
+ specs = self.parse_passthrough(passthrough)
99
+ if not specs:
100
+ return []
101
+ config = self.ports.load()
102
+ existing = set(self.configured_specs(config))
103
+ if all(spec in existing for spec in specs):
104
+ return []
105
+ merged = [
106
+ spec
107
+ for spec in self.configured_specs(config)
108
+ if spec != self.builtin_spec
109
+ ]
110
+ added: list[str] = []
111
+ for spec in specs:
112
+ if spec not in existing and spec != self.builtin_spec:
113
+ merged.append(spec)
114
+ existing.add(spec)
115
+ added.append(spec)
116
+ if not added:
117
+ return []
118
+ config.setdefault("claude_code", {})["channels"] = merged
119
+ self.ports.save(config)
120
+ self.ports.invalidate()
121
+ self.ports.log(
122
+ "INFO",
123
+ f"channels_auto_imported_from_passthrough count={len(added)} "
124
+ f"specs={','.join(added)}",
125
+ )
126
+ return added
127
+
128
+ def launch_specs(
129
+ self,
130
+ config: dict[str, Any],
131
+ extra_specs: list[str] | None = None,
132
+ ) -> list[str]:
133
+ specs = [
134
+ spec
135
+ for spec in self.configured_specs(config)
136
+ if self.is_tagged(spec)
137
+ ]
138
+ if extra_specs:
139
+ specs.extend(extra_specs)
140
+ return self.ports.dedupe(spec for spec in specs if self.is_tagged(spec))
141
+
142
+ @staticmethod
143
+ def normalize_delivery(value: Any) -> str:
144
+ text = str(value or "").strip().lower().replace("_", "-")
145
+ if text in {
146
+ "native",
147
+ "native-channel",
148
+ "native-channel-bridge",
149
+ "claude-channel",
150
+ "claude/native",
151
+ }:
152
+ return "native"
153
+ if text in {
154
+ "stdin",
155
+ "pty",
156
+ "terminal",
157
+ "wake",
158
+ "wake-proxy",
159
+ "legacy",
160
+ }:
161
+ return "stdin"
162
+ return "llm"
163
+
164
+ def delivery_mode(self, config: dict[str, Any] | None = None) -> str:
165
+ environment_value = self.ports.environment.get("CIEL_RUNTIME_CHANNEL_DELIVERY")
166
+ if environment_value is not None:
167
+ return self.normalize_delivery(environment_value)
168
+ config = config or self.ports.load()
169
+ value = config.setdefault("claude_code", {}).get("channel_delivery", "llm")
170
+ return self.normalize_delivery(value)
171
+
172
+ def set_delivery(self, value: Any) -> list[str]:
173
+ mode = self.normalize_delivery(value)
174
+ config = self.ports.load()
175
+ config.setdefault("claude_code", {})["channel_delivery"] = mode
176
+ self.ports.save(config)
177
+ messages = {
178
+ "native": "Channel delivery set to native claude/channel bridge.",
179
+ "llm": "Channel delivery set to LLM context injection.",
180
+ "stdin": "Channel delivery set to stdin wake proxy.",
181
+ }
182
+ return [messages[mode]]
183
+
184
+ def add(self, spec: str) -> list[str]:
185
+ spec = spec.strip()
186
+ if not spec:
187
+ return ["Channel spec was empty."]
188
+ if not self.is_tagged(spec):
189
+ return ["Channel spec must start with plugin: or server:."]
190
+ if spec == self.builtin_spec:
191
+ return ["Ciel Runtime router channel is always enabled."]
192
+ config = self.ports.load()
193
+ channels = [
194
+ item
195
+ for item in self.configured_specs(config)
196
+ if item != self.builtin_spec
197
+ ]
198
+ if spec not in channels:
199
+ channels.append(spec)
200
+ config.setdefault("claude_code", {})["channels"] = channels
201
+ self.ports.save(config)
202
+ return [f"Channel added: {spec}."]
203
+
204
+ def remove(self, spec: str) -> list[str]:
205
+ if spec == self.builtin_spec:
206
+ return ["Ciel Runtime router channel is always enabled and cannot be removed."]
207
+ config = self.ports.load()
208
+ before = [
209
+ item
210
+ for item in self.configured_specs(config)
211
+ if item != self.builtin_spec
212
+ ]
213
+ after = [item for item in before if item != spec]
214
+ config.setdefault("claude_code", {})["channels"] = after
215
+ self.ports.save(config)
216
+ message = (
217
+ f"Channel removed: {spec}."
218
+ if len(after) != len(before)
219
+ else f"Channel was not configured: {spec}."
220
+ )
221
+ return [message]
222
+
223
+ def clear(self) -> list[str]:
224
+ config = self.ports.load()
225
+ config.setdefault("claude_code", {})["channels"] = []
226
+ self.ports.save(config)
227
+ return [
228
+ "External Claude Code channels cleared. Ciel Runtime router remains enabled."
229
+ ]
230
+
231
+
232
+ @dataclass(frozen=True, slots=True)
233
+ class ChannelConfigApi:
234
+ """Explicit public adapter for late-bound Channel configuration services."""
235
+
236
+ service_factory: Callable[[], ChannelConfigService]
237
+
238
+ def parse_passthrough_channel_specs(self, passthrough: list[str]) -> list[str]:
239
+ return self.service_factory().parse_passthrough(passthrough)
240
+
241
+ def auto_import_passthrough_channels(self, passthrough: list[str]) -> list[str]:
242
+ return self.service_factory().auto_import(passthrough)
243
+
244
+ def normalize_channel_passthrough(
245
+ self,
246
+ passthrough: list[str],
247
+ ) -> list[str]:
248
+ return self.service_factory().normalize_passthrough(passthrough)
249
+
250
+ def channel_specs_for_launch(
251
+ self,
252
+ cfg: dict[str, Any],
253
+ passthrough: list[str],
254
+ extra_specs: list[str] | None = None,
255
+ ) -> list[str]:
256
+ del passthrough
257
+ return self.service_factory().launch_specs(cfg, extra_specs)
258
+
259
+ def is_channel_spec_tagged(self, spec: str) -> bool:
260
+ return self.service_factory().is_tagged(spec)
261
+
262
+ def normalize_channel_delivery(self, value: Any) -> str:
263
+ return self.service_factory().normalize_delivery(value)
264
+
265
+ def channel_delivery_mode(self, cfg: dict[str, Any] | None = None) -> str:
266
+ return self.service_factory().delivery_mode(cfg)
267
+
268
+ def set_channel_delivery_config(self, value: Any) -> list[str]:
269
+ return self.service_factory().set_delivery(value)
270
+
271
+ def add_channel_spec(
272
+ self, spec: str, *, development: bool = False
273
+ ) -> list[str]:
274
+ del development
275
+ return self.service_factory().add(spec)
276
+
277
+ def remove_channel_spec(self, spec: str) -> list[str]:
278
+ return self.service_factory().remove(spec)
279
+
280
+ def clear_channel_specs(self) -> list[str]:
281
+ return self.service_factory().clear()
@@ -0,0 +1,180 @@
1
+ """Lifecycle service for creating and stopping channel transport connections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ import urllib.parse
8
+ import uuid
9
+ from dataclasses import dataclass
10
+ from threading import Lock
11
+ from typing import Any, Callable
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class ChannelConnectionLifecycleEffects:
16
+ safe_segment: Callable[[str, str], str]
17
+ close_session: Callable[[dict[str, Any], str], None]
18
+ cleanup_stale_sessions: Callable[[str, str, dict[str, str], str], None]
19
+ public_status: Callable[[str, dict[str, Any]], dict[str, Any]]
20
+ all_statuses: Callable[[], dict[str, Any]]
21
+ sse_worker: Callable[[str, str | None], None]
22
+ streamable_http_worker: Callable[[str, str | None], None]
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class ChannelConnectionLifecyclePolicy:
27
+ streamable_protocol_version: str
28
+ legacy_sse_protocol_version: str
29
+ parse_bool: Callable[[Any, bool], bool]
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class ChannelConnectionLifecycleStore:
34
+ states: dict[str, dict[str, Any]]
35
+ lock: Lock
36
+
37
+ def replace(self, name: str, state: dict[str, Any]) -> dict[str, Any] | None:
38
+ with self.lock:
39
+ prior = self.states.get(name)
40
+ if prior:
41
+ prior["running"] = False
42
+ self.states[name] = state
43
+ return dict(prior) if prior else None
44
+
45
+ def stop(self, name: str | None) -> tuple[list[str], list[dict[str, Any]]]:
46
+ stopped: list[str] = []
47
+ states_to_close: list[dict[str, Any]] = []
48
+ with self.lock:
49
+ targets = [name] if name else list(self.states)
50
+ for target in targets:
51
+ if not target:
52
+ continue
53
+ state = self.states.get(target)
54
+ if state:
55
+ state["running"] = False
56
+ states_to_close.append(dict(state))
57
+ stopped.append(target)
58
+ return stopped, states_to_close
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class ChannelConnectionLifecycle:
63
+ store: ChannelConnectionLifecycleStore
64
+ effects: ChannelConnectionLifecycleEffects
65
+ policy: ChannelConnectionLifecyclePolicy
66
+
67
+ @staticmethod
68
+ def _headers(config: dict[str, Any]) -> dict[str, str]:
69
+ raw_headers = config.get("headers") if isinstance(config.get("headers"), dict) else {}
70
+ headers = {str(key): str(value) for key, value in raw_headers.items() if str(key).strip()}
71
+ token = str(config.get("bearer_token") or config.get("token") or "").strip()
72
+ if token and "Authorization" not in headers:
73
+ headers["Authorization"] = f"Bearer {token}"
74
+ return headers
75
+
76
+ @staticmethod
77
+ def _event_filter(config: dict[str, Any]) -> list[str]:
78
+ event_filter = config.get("event_filter")
79
+ if isinstance(event_filter, str):
80
+ return [item.strip() for item in event_filter.split(",") if item.strip()]
81
+ if isinstance(event_filter, list):
82
+ return [str(item).strip() for item in event_filter if str(item).strip()]
83
+ return []
84
+
85
+ def _new_state(
86
+ self,
87
+ config: dict[str, Any],
88
+ name: str,
89
+ url: str,
90
+ transport: str,
91
+ headers: dict[str, str],
92
+ connection_id: str,
93
+ ) -> dict[str, Any]:
94
+ protocol_version = (
95
+ self.policy.streamable_protocol_version
96
+ if transport == "streamable-http"
97
+ else self.policy.legacy_sse_protocol_version
98
+ )
99
+ return {
100
+ "name": name,
101
+ "connection_id": connection_id,
102
+ "url": url,
103
+ "headers": headers,
104
+ "channel": str(config.get("channel") or "default"),
105
+ "sender_id": str(config.get("sender_id") or config.get("sender") or name),
106
+ "recipient": str(config.get("recipient") or config.get("recipient_id") or config.get("to") or "all"),
107
+ "running": True,
108
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
109
+ "last_event_at": None,
110
+ "messages_received": 0,
111
+ "last_error": None,
112
+ "event_filter": self._event_filter(config),
113
+ "last_sse_event_id": str(
114
+ config.get("last_sse_event_id")
115
+ or config.get("last_event_id")
116
+ or config.get("lastEventId")
117
+ or ""
118
+ ).strip(),
119
+ "sse_reconnects": 0,
120
+ "read_timeout_seconds": float(config.get("read_timeout_seconds") or config.get("timeout") or 300.0),
121
+ "retry_seconds": float(config.get("retry_seconds") or 5.0),
122
+ "mcp_enabled": bool(config.get("mcp", config.get("mcp_enabled", True))),
123
+ "mcp_endpoint": None,
124
+ "mcp_initialized": False,
125
+ "mcp_session_id": None,
126
+ "mcp_last_error": None,
127
+ "mcp_rpc_results": {},
128
+ "transport": transport,
129
+ "streamable_requires_session": self.policy.parse_bool(
130
+ config.get(
131
+ "streamable_requires_session",
132
+ config.get("require_session", config.get("mcp_session_required", True)),
133
+ ),
134
+ True,
135
+ ),
136
+ "mcp_protocol_version": str(config.get("mcp_protocol_version") or protocol_version),
137
+ "mcp_timeout_seconds": float(config.get("mcp_timeout_seconds") or 20.0),
138
+ }
139
+
140
+ def start(self, config: dict[str, Any]) -> dict[str, Any]:
141
+ url = str(config.get("url") or "").strip()
142
+ if not url.startswith(("http://", "https://")):
143
+ raise ValueError("SSE url must start with http:// or https://")
144
+ name = self.effects.safe_segment(
145
+ str(config.get("name") or urllib.parse.urlparse(url).netloc or "sse"), "sse"
146
+ )
147
+ declared_transport = str(config.get("transport") or config.get("type") or "").strip().lower()
148
+ transport = "streamable-http" if declared_transport in {"http", "streamable-http"} else "sse"
149
+ headers = self._headers(config)
150
+ connection_id = uuid.uuid4().hex
151
+ state = self._new_state(config, name, url, transport, headers, connection_id)
152
+ prior_state = self.store.replace(name, state)
153
+ if prior_state:
154
+ self.effects.close_session(prior_state, "replace_connection")
155
+ if transport == "streamable-http":
156
+ self.effects.cleanup_stale_sessions(
157
+ name,
158
+ url,
159
+ headers,
160
+ str(state.get("mcp_protocol_version") or self.policy.streamable_protocol_version),
161
+ )
162
+ worker = (
163
+ self.effects.streamable_http_worker
164
+ if transport == "streamable-http"
165
+ else self.effects.sse_worker
166
+ )
167
+ thread = threading.Thread(
168
+ target=worker,
169
+ args=(name, connection_id),
170
+ daemon=True,
171
+ name=f"ciel-runtime-channel-{transport}-{name}",
172
+ )
173
+ thread.start()
174
+ return self.effects.public_status(name, state)
175
+
176
+ def stop(self, name: str | None = None) -> dict[str, Any]:
177
+ stopped, states_to_close = self.store.stop(name)
178
+ for state in states_to_close:
179
+ self.effects.close_session(state, "stop_connection")
180
+ return {"stopped": stopped, "connections": self.effects.all_statuses()}
@@ -0,0 +1,128 @@
1
+ """Thread-safe repository for live channel transport connection state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from dataclasses import dataclass
8
+ from threading import Condition, Lock
9
+ from typing import Any, Callable
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class ChannelConnectionRegistry:
14
+ states: dict[str, dict[str, Any]]
15
+ lock: Lock
16
+ rpc_condition: Condition
17
+ log: Callable[[str, str], None]
18
+ clock: Callable[[], float] = time.time
19
+
20
+ @staticmethod
21
+ def public_status(name: str, state: dict[str, Any]) -> dict[str, Any]:
22
+ return {
23
+ "name": name,
24
+ "url": state.get("url"),
25
+ "channel": state.get("channel"),
26
+ "sender_id": state.get("sender_id"),
27
+ "recipient": state.get("recipient"),
28
+ "running": bool(state.get("running")),
29
+ "started_at": state.get("started_at"),
30
+ "last_event_at": state.get("last_event_at"),
31
+ "messages_received": int(state.get("messages_received") or 0),
32
+ "event_filter": state.get("event_filter") or [],
33
+ "read_timeout_seconds": state.get("read_timeout_seconds"),
34
+ "last_sse_event_id": state.get("last_sse_event_id"),
35
+ "sse_reconnects": int(state.get("sse_reconnects") or 0),
36
+ "transport": state.get("transport") or "sse",
37
+ "mcp_endpoint": state.get("mcp_endpoint"),
38
+ "mcp_initialized": bool(state.get("mcp_initialized")),
39
+ "mcp_session_id": state.get("mcp_session_id"),
40
+ "mcp_protocol_version": state.get("mcp_protocol_version"),
41
+ "mcp_last_error": state.get("mcp_last_error"),
42
+ "last_error": state.get("last_error"),
43
+ }
44
+
45
+ def statuses(self) -> dict[str, Any]:
46
+ with self.lock:
47
+ return {name: self.public_status(name, state) for name, state in self.states.items()}
48
+
49
+ def update(self, name: str, **updates: Any) -> None:
50
+ with self.lock:
51
+ if state := self.states.get(name):
52
+ state.update(updates)
53
+
54
+ def mark_session_lost(self, name: str, reason: str) -> None:
55
+ with self.lock:
56
+ state = self.states.get(name)
57
+ if not state:
58
+ return
59
+ state.update(
60
+ mcp_initialized=False,
61
+ mcp_session_id=None,
62
+ mcp_last_error=reason,
63
+ last_error=reason,
64
+ sse_reconnects=int(state.get("sse_reconnects") or 0) + 1,
65
+ )
66
+
67
+ def store_rpc_response(self, name: str, data_text: str) -> bool:
68
+ try:
69
+ payload = json.loads(str(data_text or "").strip())
70
+ except (json.JSONDecodeError, TypeError):
71
+ return False
72
+ if (
73
+ not isinstance(payload, dict)
74
+ or payload.get("id") is None
75
+ or ("result" not in payload and "error" not in payload)
76
+ ):
77
+ return False
78
+ rpc_id = str(payload["id"])
79
+ with self.rpc_condition:
80
+ with self.lock:
81
+ state = self.states.get(name)
82
+ if not state:
83
+ return True
84
+ results = state.get("mcp_rpc_results")
85
+ if not isinstance(results, dict):
86
+ results = {}
87
+ state["mcp_rpc_results"] = results
88
+ results[rpc_id] = payload
89
+ for old_id in list(results)[: max(0, len(results) - 200)]:
90
+ results.pop(old_id, None)
91
+ self.rpc_condition.notify_all()
92
+ self.log("INFO", f"channel_sse_mcp_rpc_response name={name} id={rpc_id}")
93
+ return True
94
+
95
+ def take_rpc_response(
96
+ self, name: str, rpc_id: Any, timeout: float
97
+ ) -> dict[str, Any] | None:
98
+ key = str(rpc_id)
99
+ deadline = self.clock() + max(0.1, timeout)
100
+ with self.rpc_condition:
101
+ while True:
102
+ with self.lock:
103
+ state = self.states.get(name)
104
+ results = state.get("mcp_rpc_results") if isinstance(state, dict) else None
105
+ if isinstance(results, dict) and key in results:
106
+ found = results.pop(key)
107
+ return found if isinstance(found, dict) else None
108
+ remaining = deadline - self.clock()
109
+ if remaining <= 0:
110
+ return None
111
+ self.rpc_condition.wait(min(remaining, 1.0))
112
+
113
+ @staticmethod
114
+ def public_mcp_name(name: str) -> str:
115
+ text = str(name or "").strip()
116
+ return text[4:] if text.startswith("mcp-") else text
117
+
118
+ def state_name_for_mcp_server(self, server_name: str) -> str | None:
119
+ text = str(server_name or "").strip()
120
+ candidates = [text, text[4:] if text.startswith("mcp-") else f"mcp-{text}"] if text else []
121
+ with self.lock:
122
+ for candidate in candidates:
123
+ if candidate in self.states:
124
+ return candidate
125
+ return next(
126
+ (name for name in self.states if self.public_mcp_name(name) == text),
127
+ None,
128
+ )