@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,366 @@
1
+ """Managed router client leases, supervision, and launch diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import threading
10
+ import time
11
+ from typing import Any, Callable
12
+
13
+
14
+ Log = Callable[[str, str], None]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class RouterClientRegistryPorts:
19
+ pid_is_running: Callable[[int], bool]
20
+ log: Log
21
+ current_pids: Callable[[], frozenset[int]] = lambda: frozenset(
22
+ (os.getpid(), os.getppid())
23
+ )
24
+ wrapper_parent_pids: Callable[[int], list[int]] = lambda _pid: []
25
+ terminate_tree: Callable[..., bool] = lambda *_args, **_kwargs: False
26
+
27
+
28
+ class RouterClientRegistry:
29
+ def __init__(self, clients_dir: Path, router_port: int, ports: RouterClientRegistryPorts) -> None:
30
+ self._clients_dir = clients_dir
31
+ self._router_port = router_port
32
+ self._ports = ports
33
+
34
+ def register(self, pid: int | None = None) -> Path:
35
+ client_pid = int(pid or os.getpid())
36
+ self._clients_dir.mkdir(parents=True, exist_ok=True)
37
+ path = self._clients_dir / f"{client_pid}.json"
38
+ payload = {
39
+ "pid": client_pid,
40
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
41
+ "router_port": self._router_port,
42
+ }
43
+ path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
44
+ try:
45
+ os.chmod(path, 0o600)
46
+ except OSError:
47
+ pass
48
+ self._ports.log("INFO", f"router_client_registered pid={client_pid} path={path}")
49
+ return path
50
+
51
+ def release(self, path: Path | None) -> None:
52
+ if path is None:
53
+ return
54
+ try:
55
+ path.unlink()
56
+ self._ports.log("INFO", f"router_client_released path={path}")
57
+ except FileNotFoundError:
58
+ pass
59
+ except OSError as exc:
60
+ self._ports.log("WARN", f"router_client_release_failed path={path} error={type(exc).__name__}: {exc}")
61
+
62
+ def release_pid(self, pid: int) -> None:
63
+ self.release(self._clients_dir / f"{pid}.json")
64
+
65
+ def active_pids(self) -> list[int]:
66
+ if not self._clients_dir.exists():
67
+ return []
68
+ active: list[int] = []
69
+ for path in self._clients_dir.glob("*.json"):
70
+ try:
71
+ data = json.loads(path.read_text(encoding="utf-8"))
72
+ pid = int(data.get("pid") or path.stem)
73
+ except (OSError, ValueError, TypeError, json.JSONDecodeError):
74
+ try:
75
+ pid = int(path.stem)
76
+ except ValueError:
77
+ pid = 0
78
+ if self._ports.pid_is_running(pid):
79
+ active.append(pid)
80
+ continue
81
+ try:
82
+ path.unlink()
83
+ self._ports.log("INFO", f"router_client_stale_removed pid={pid or '-'} path={path}")
84
+ except OSError:
85
+ pass
86
+ return sorted(set(active))
87
+
88
+ def terminate_active(
89
+ self,
90
+ reason: str,
91
+ active_clients: list[int] | None = None,
92
+ *,
93
+ quiet: bool = True,
94
+ ) -> bool:
95
+ clients = (
96
+ active_clients
97
+ if active_clients is not None
98
+ else self.active_pids()
99
+ )
100
+ stopped = False
101
+ for pid in sorted({int(value) for value in clients if int(value or 0) > 0}):
102
+ if pid in self._ports.current_pids():
103
+ continue
104
+ wrapper_roots = self._ports.wrapper_parent_pids(pid)
105
+ root = wrapper_roots[-1] if wrapper_roots else pid
106
+ if self._ports.terminate_tree(
107
+ root, "previous ciel-runtime client", quiet=quiet
108
+ ):
109
+ stopped = True
110
+ self.release_pid(pid)
111
+ self._ports.log(
112
+ "INFO",
113
+ f"router_active_clients_terminated reason={reason} "
114
+ f"clients={','.join(map(str, clients)) or '-'} "
115
+ f"stopped={str(stopped).lower()}",
116
+ )
117
+ return stopped
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class ManagedRouterLifetimePorts:
122
+ active_client_pids: Callable[[], list[int]]
123
+ pid_is_running: Callable[[int], bool]
124
+ stop_router: Callable[..., bool]
125
+ log: Log
126
+
127
+
128
+ class ManagedRouterLifetime:
129
+ def __init__(self, ports: ManagedRouterLifetimePorts) -> None:
130
+ self._ports = ports
131
+
132
+ @staticmethod
133
+ def idle_exit_seconds() -> float:
134
+ try:
135
+ value = float(os.environ.get("CIEL_RUNTIME_ROUTER_IDLE_EXIT_SECONDS", "90"))
136
+ except (TypeError, ValueError):
137
+ value = 90.0
138
+ return max(0.0, value)
139
+
140
+ def stop_reason(self, started_at: float, owner_pid: int, idle_seconds: float) -> str | None:
141
+ if os.environ.get("CIEL_RUNTIME_MANAGED_ROUTER") != "1":
142
+ return None
143
+ if self._ports.active_client_pids():
144
+ return None
145
+ if owner_pid > 0 and not self._ports.pid_is_running(owner_pid):
146
+ return "owner_dead_no_clients"
147
+ if idle_seconds > 0 and time.time() - started_at >= idle_seconds:
148
+ return "idle_no_clients"
149
+ return None
150
+
151
+ def start_watchdog(self, server: Any) -> None:
152
+ if os.environ.get("CIEL_RUNTIME_MANAGED_ROUTER") != "1":
153
+ return
154
+ try:
155
+ owner_pid = int(os.environ.get("CIEL_RUNTIME_ROUTER_OWNER_PID") or "0")
156
+ except ValueError:
157
+ owner_pid = 0
158
+ idle_seconds = self.idle_exit_seconds()
159
+ started_at = time.time()
160
+
161
+ def watch() -> None:
162
+ interval = min(5.0, max(0.5, idle_seconds / 3.0 if idle_seconds else 5.0))
163
+ while True:
164
+ time.sleep(interval)
165
+ reason = self.stop_reason(started_at, owner_pid, idle_seconds)
166
+ if not reason:
167
+ continue
168
+ self._ports.log("INFO", f"router_managed_lifetime_shutdown reason={reason} owner_pid={owner_pid or '-'}")
169
+ try:
170
+ server.shutdown()
171
+ except Exception as exc:
172
+ self._ports.log("ERROR", f"router_managed_lifetime_shutdown_failed error={type(exc).__name__}: {exc}")
173
+ return
174
+
175
+ threading.Thread(target=watch, daemon=True, name="ca-router-lifetime-watchdog").start()
176
+
177
+ def stop_if_idle(self, reason: str, quiet: bool = True) -> bool:
178
+ active = self._ports.active_client_pids()
179
+ if active:
180
+ self._ports.log("INFO", f"router_lifetime_keep_alive reason={reason} active_clients={','.join(map(str, active))}")
181
+ return False
182
+ try:
183
+ stopped = self._ports.stop_router(reason, quiet=quiet)
184
+ self._ports.log("INFO", f"router_lifetime_stopped reason={reason} stopped={stopped}")
185
+ return stopped
186
+ except Exception as exc:
187
+ self._ports.log("ERROR", f"router_lifetime_stop_failed reason={reason} error={type(exc).__name__}: {exc}")
188
+ return False
189
+
190
+
191
+ @dataclass(frozen=True)
192
+ class RouterClientSupervisorPorts:
193
+ router_health: Callable[[], Any]
194
+ health_matches_current: Callable[[Any], bool]
195
+ health_summary: Callable[[Any], str]
196
+ start_router: Callable[..., bool]
197
+ log: Log
198
+
199
+
200
+ class RouterClientSupervisor:
201
+ def __init__(self, router_base: str, ports: RouterClientSupervisorPorts) -> None:
202
+ self._router_base = router_base
203
+ self._ports = ports
204
+
205
+ @staticmethod
206
+ def interval_seconds() -> float:
207
+ try:
208
+ value = float(os.environ.get("CIEL_RUNTIME_ROUTER_SUPERVISOR_SECONDS", "0.5"))
209
+ except (TypeError, ValueError):
210
+ value = 0.5
211
+ return max(0.5, min(30.0, value))
212
+
213
+ def ensure_running(self) -> bool:
214
+ health = self._ports.router_health()
215
+ if self._ports.health_matches_current(health):
216
+ return True
217
+ if health is not None:
218
+ self._ports.log("WARN", f"router_lifetime_health_mismatch_active_client {self._ports.health_summary(health)}")
219
+ return self._restart()
220
+ for attempt in range(2):
221
+ time.sleep(0.5)
222
+ health = self._ports.router_health()
223
+ if self._ports.health_matches_current(health):
224
+ self._ports.log(
225
+ "INFO",
226
+ f"router_lifetime_keep_alive reason=transient_health_miss retry={attempt + 1} {self._ports.health_summary(health)}",
227
+ )
228
+ return True
229
+ self._ports.log("WARN", f"router_lifetime_restart reason=router_down_active_client base={self._router_base}")
230
+ return self._restart()
231
+
232
+ def _restart(self) -> bool:
233
+ try:
234
+ return bool(self._ports.start_router(replace_active_clients=False))
235
+ except Exception as exc:
236
+ self._ports.log("ERROR", f"router_lifetime_restart_failed error={type(exc).__name__}: {exc}")
237
+ return False
238
+
239
+ def start(self, stop_event: threading.Event) -> threading.Thread:
240
+ def watch() -> None:
241
+ interval = self.interval_seconds()
242
+ while not stop_event.wait(interval):
243
+ self.ensure_running()
244
+
245
+ thread = threading.Thread(target=watch, daemon=True, name="ca-router-client-supervisor")
246
+ thread.start()
247
+ return thread
248
+
249
+
250
+ @dataclass(frozen=True)
251
+ class RouterLifetimeRunnerPorts:
252
+ register_client: Callable[[], Path]
253
+ release_client: Callable[[Path | None], None]
254
+ start_supervisor: Callable[[threading.Event], threading.Thread]
255
+ stop_if_idle: Callable[..., bool]
256
+ log: Log
257
+
258
+
259
+ class RouterLifetimeRunner:
260
+ def __init__(self, ports: RouterLifetimeRunnerPorts) -> None:
261
+ self._ports = ports
262
+
263
+ def run(self, runner: Callable[[], int], manage_router: bool) -> int:
264
+ client_path: Path | None = None
265
+ supervisor_stop: threading.Event | None = None
266
+ if manage_router:
267
+ try:
268
+ client_path = self._ports.register_client()
269
+ supervisor_stop = threading.Event()
270
+ self._ports.start_supervisor(supervisor_stop)
271
+ except Exception as exc:
272
+ self._ports.log("WARN", f"router_client_register_failed error={type(exc).__name__}: {exc}")
273
+ try:
274
+ return runner()
275
+ finally:
276
+ if supervisor_stop is not None:
277
+ supervisor_stop.set()
278
+ if manage_router:
279
+ self._ports.release_client(client_path)
280
+ self._ports.stop_if_idle("claude_exit", quiet=True)
281
+
282
+
283
+ @dataclass(frozen=True)
284
+ class RoutedLaunchDiagnosticPorts:
285
+ router_health: Callable[[], Any]
286
+ health_summary: Callable[[Any], str]
287
+ provider_summary: Callable[[str, dict[str, Any]], str]
288
+ log: Log
289
+
290
+
291
+ class RoutedLaunchDiagnostics:
292
+ MARKERS = (
293
+ "connectionrefused",
294
+ "connection refused",
295
+ "urlerror",
296
+ "router_lifetime_restart_failed",
297
+ "router_lifetime_health_mismatch",
298
+ "anthropic_sse_forward_error",
299
+ )
300
+ LOG_MARKERS = (
301
+ "[ERROR]", "[WARN]", "ConnectionRefused", "connection refused", "URLError",
302
+ "router_lifetime", "router_spawned", "router_check_state", "claude_exit",
303
+ "upstream_", "ollama_", "anthropic_sse_forward_error",
304
+ )
305
+
306
+ def __init__(self, router_base: str, log_path: Path, ports: RoutedLaunchDiagnosticPorts) -> None:
307
+ self._router_base = router_base
308
+ self._log_path = log_path
309
+ self._ports = ports
310
+
311
+ @staticmethod
312
+ def file_size(path: Path) -> int:
313
+ try:
314
+ return int(path.stat().st_size)
315
+ except OSError:
316
+ return 0
317
+
318
+ @staticmethod
319
+ def read_from_offset(path: Path, offset: int = 0, max_bytes: int = 262_144) -> str:
320
+ try:
321
+ size = path.stat().st_size
322
+ start = max(0, min(int(offset or 0), int(size)))
323
+ if size - start > max_bytes:
324
+ start = max(0, size - max_bytes)
325
+ with path.open("rb") as stream:
326
+ stream.seek(start)
327
+ return stream.read(max_bytes).decode("utf-8", errors="replace")
328
+ except OSError:
329
+ return ""
330
+
331
+ def recent_lines(self, since_offset: int = 0, limit: int = 8) -> list[str]:
332
+ text = self.read_from_offset(self._log_path, since_offset)
333
+ lines = [
334
+ line.strip() for line in text.splitlines()
335
+ if line.strip() and any(marker in line for marker in self.LOG_MARKERS)
336
+ ]
337
+ return lines[-max(1, int(limit or 8)):]
338
+
339
+ @classmethod
340
+ def should_print(cls, return_code: int, recent_lines: list[str]) -> bool:
341
+ return return_code != 0 or any(marker in "\n".join(recent_lines).lower() for marker in cls.MARKERS)
342
+
343
+ def print_exit(self, return_code: int, provider: str, config: dict[str, Any], *, log_offset: int = 0) -> None:
344
+ recent = self.recent_lines(log_offset)
345
+ if not self.should_print(return_code, recent):
346
+ return
347
+ lines = [
348
+ f"Ciel Runtime diagnostic: Claude Code exited with code {return_code} while routed through {self._router_base}.",
349
+ f"Router: {self._ports.health_summary(self._ports.router_health())}",
350
+ f"Provider: {provider} {self._ports.provider_summary(provider, config)}",
351
+ f"Router log: {self._log_path}",
352
+ ]
353
+ if recent:
354
+ lines.append("Recent router events:")
355
+ lines.extend(f" {line}" for line in recent)
356
+ for line in lines:
357
+ print(line, flush=True)
358
+ self._ports.log("WARN", "claude_routed_exit_diagnostic " + " | ".join(lines[:4]))
359
+
360
+
361
+ __all__ = [
362
+ "ManagedRouterLifetime", "ManagedRouterLifetimePorts", "RoutedLaunchDiagnosticPorts",
363
+ "RoutedLaunchDiagnostics", "RouterClientRegistry", "RouterClientRegistryPorts",
364
+ "RouterClientSupervisor", "RouterClientSupervisorPorts", "RouterLifetimeRunner",
365
+ "RouterLifetimeRunnerPorts",
366
+ ]
@@ -0,0 +1,101 @@
1
+ """Router health identity and diagnostic projection policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class RouterHealthPolicy:
12
+ version: str
13
+ source_fingerprint: str
14
+ config_dir: Path
15
+ router_base: str
16
+ pid_path: Path
17
+ current_user: Callable[[], str]
18
+ health: Callable[[], dict[str, Any] | None]
19
+ connectivity_summary: Callable[[], str]
20
+
21
+ def summary(
22
+ self,
23
+ health: dict[str, Any] | None = None,
24
+ ) -> str:
25
+ current = self.health() if health is None else health
26
+ if isinstance(current, dict):
27
+ return (
28
+ "health=ok "
29
+ f"base={self.router_base} "
30
+ f"pid={current.get('pid') or '-'} "
31
+ f"version={current.get('version') or '-'} "
32
+ "source="
33
+ f"{current.get('source_fingerprint') or '-'} "
34
+ f"provider={current.get('provider') or '-'} "
35
+ f"model={current.get('model') or '-'} "
36
+ f"config_dir={current.get('config_dir') or '-'}"
37
+ )
38
+ return (
39
+ f"health=down base={self.router_base} "
40
+ f"pid_file={self._pid_state()} "
41
+ f"{self.connectivity_summary()}"
42
+ )
43
+
44
+ def matches_current(
45
+ self,
46
+ health: dict[str, Any] | None,
47
+ ) -> bool:
48
+ if health is None:
49
+ return False
50
+ return (
51
+ str(health.get("version") or "") == self.version
52
+ and str(health.get("source_fingerprint") or "")
53
+ == self.source_fingerprint
54
+ and str(health.get("user") or "") == self.current_user()
55
+ and self.config_matches_current(health)
56
+ )
57
+
58
+ def config_matches_current(
59
+ self,
60
+ health: dict[str, Any] | None,
61
+ ) -> bool:
62
+ if not isinstance(health, dict):
63
+ return False
64
+ return self.path_identity(
65
+ health.get("config_dir")
66
+ ) == self.path_identity(self.config_dir)
67
+
68
+ def has_foreign_config(
69
+ self,
70
+ health: dict[str, Any] | None,
71
+ ) -> bool:
72
+ if not isinstance(health, dict):
73
+ return False
74
+ config_dir = self.path_identity(health.get("config_dir"))
75
+ return bool(config_dir) and config_dir != self.path_identity(
76
+ self.config_dir
77
+ )
78
+
79
+ @staticmethod
80
+ def path_identity(value: Any) -> str:
81
+ text = str(value or "").strip()
82
+ if not text:
83
+ return ""
84
+ try:
85
+ return str(Path(text).expanduser().resolve(strict=False))
86
+ except Exception:
87
+ return text
88
+
89
+ def _pid_state(self) -> str:
90
+ try:
91
+ if self.pid_path.exists():
92
+ return (
93
+ self.pid_path.read_text(encoding="utf-8").strip()
94
+ or "empty"
95
+ )
96
+ except Exception as exc:
97
+ return f"read_failed:{type(exc).__name__}"
98
+ return "missing"
99
+
100
+
101
+ __all__ = ["RouterHealthPolicy"]