@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,708 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import ipaddress
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ import signal
10
+ import subprocess
11
+ import time
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Callable
15
+
16
+
17
+ def pid_is_running(pid: int) -> bool:
18
+ """Check process liveness using the host platform's stable primitive."""
19
+
20
+ if pid <= 0:
21
+ return False
22
+ if os.name == "nt":
23
+ try:
24
+ process = subprocess.run(
25
+ ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
26
+ text=True,
27
+ stdout=subprocess.PIPE,
28
+ stderr=subprocess.DEVNULL,
29
+ timeout=3,
30
+ )
31
+ except Exception:
32
+ return False
33
+ output = process.stdout or ""
34
+ return str(pid) in output and "No tasks" not in output and "INFO:" not in output
35
+ try:
36
+ os.kill(pid, 0)
37
+ return True
38
+ except (OSError, SystemError):
39
+ return False
40
+
41
+
42
+ def _normalized_listener_host(host: str) -> str:
43
+ value = str(host or "").strip().lower()
44
+ if value.startswith("[") and value.endswith("]"):
45
+ value = value[1:-1]
46
+ if value == "localhost":
47
+ return "127.0.0.1"
48
+ try:
49
+ return str(ipaddress.ip_address(value.split("%", 1)[0]))
50
+ except ValueError:
51
+ return value
52
+
53
+
54
+ def _listener_host_conflicts(local_host: str, bind_host: str | None) -> bool:
55
+ if not bind_host:
56
+ return True
57
+ local = _normalized_listener_host(local_host)
58
+ wanted = _normalized_listener_host(bind_host)
59
+ if local in {"0.0.0.0", "::", "*"} or wanted in {"0.0.0.0", "::", "*"}:
60
+ return True
61
+ try:
62
+ local_ip = ipaddress.ip_address(local)
63
+ wanted_ip = ipaddress.ip_address(wanted)
64
+ except ValueError:
65
+ return local == wanted
66
+ return local_ip == wanted_ip
67
+
68
+
69
+ def _split_listener_endpoint(endpoint: str) -> tuple[str, str]:
70
+ value = str(endpoint or "").strip()
71
+ if value.startswith("[") and "]:" in value:
72
+ closing = value.rfind("]:")
73
+ return value[1:closing], value[closing + 2:]
74
+ if ":" not in value:
75
+ return value, ""
76
+ host, port = value.rsplit(":", 1)
77
+ return host, port
78
+
79
+
80
+ def windows_pids_on_port(port: int, host: str | None = None) -> list[int]:
81
+ if os.name != "nt":
82
+ return []
83
+ try:
84
+ proc = subprocess.run(
85
+ ["netstat", "-ano", "-p", "tcp"],
86
+ text=True,
87
+ stdout=subprocess.PIPE,
88
+ stderr=subprocess.DEVNULL,
89
+ timeout=5,
90
+ )
91
+ except Exception:
92
+ return []
93
+ pids: set[int] = set()
94
+ for line in proc.stdout.splitlines():
95
+ if "LISTENING" not in line:
96
+ continue
97
+ parts = line.split()
98
+ if len(parts) < 5:
99
+ continue
100
+ local_host, local_port = _split_listener_endpoint(parts[1])
101
+ if local_port != str(port) or not _listener_host_conflicts(local_host, host):
102
+ continue
103
+ try:
104
+ pids.add(int(parts[-1]))
105
+ except ValueError:
106
+ continue
107
+ return sorted(pids)
108
+
109
+
110
+ def _linux_procfs_host(encoded: str, *, ipv6: bool) -> str:
111
+ raw = bytes.fromhex(encoded)
112
+ if ipv6:
113
+ raw = b"".join(raw[index:index + 4][::-1] for index in range(0, 16, 4))
114
+ return str(ipaddress.IPv6Address(raw))
115
+ return str(ipaddress.IPv4Address(raw[::-1]))
116
+
117
+
118
+ def linux_procfs_pids_on_port(port: int, host: str | None = None) -> list[int]:
119
+ if os.name == "nt":
120
+ return []
121
+ wanted_port = f"{int(port):04X}"
122
+ inodes: set[str] = set()
123
+ for table in (Path("/proc/net/tcp"), Path("/proc/net/tcp6")):
124
+ try:
125
+ lines = table.read_text(encoding="utf-8", errors="replace").splitlines()[1:]
126
+ except Exception:
127
+ continue
128
+ for line in lines:
129
+ parts = line.split()
130
+ if len(parts) < 10:
131
+ continue
132
+ local_addr = parts[1]
133
+ state = parts[3]
134
+ inode = parts[9]
135
+ if state != "0A" or ":" not in local_addr:
136
+ continue
137
+ encoded_host, encoded_port = local_addr.rsplit(":", 1)
138
+ try:
139
+ local_host = _linux_procfs_host(encoded_host, ipv6=table.name == "tcp6")
140
+ except (ValueError, ipaddress.AddressValueError):
141
+ continue
142
+ if (
143
+ encoded_port.upper() == wanted_port
144
+ and inode
145
+ and inode != "0"
146
+ and _listener_host_conflicts(local_host, host)
147
+ ):
148
+ inodes.add(inode)
149
+ if not inodes:
150
+ return []
151
+ pids: set[int] = set()
152
+ current = os.getpid()
153
+ parent = os.getppid()
154
+ try:
155
+ proc_entries = list(Path("/proc").iterdir())
156
+ except Exception:
157
+ return []
158
+ for entry in proc_entries:
159
+ if not entry.name.isdigit():
160
+ continue
161
+ try:
162
+ pid = int(entry.name)
163
+ except ValueError:
164
+ continue
165
+ if pid in (current, parent):
166
+ continue
167
+ fd_dir = entry / "fd"
168
+ try:
169
+ fds = list(fd_dir.iterdir())
170
+ except Exception:
171
+ continue
172
+ for fd in fds:
173
+ try:
174
+ target = os.readlink(fd)
175
+ except Exception:
176
+ continue
177
+ match = re.match(r"socket:\[(\d+)\]$", target)
178
+ if match and match.group(1) in inodes:
179
+ pids.add(pid)
180
+ break
181
+ return sorted(pids)
182
+
183
+
184
+ def posix_pids_on_port(
185
+ port: int,
186
+ procfs_lookup: Callable[..., list[int]] = linux_procfs_pids_on_port,
187
+ host: str | None = None,
188
+ ) -> list[int]:
189
+ if os.name == "nt":
190
+ return []
191
+ pids: set[int] = set(procfs_lookup(port, host) if host else procfs_lookup(port))
192
+ if host and Path("/proc/net/tcp").exists():
193
+ return sorted(pid for pid in pids if pid not in (os.getpid(), os.getppid()))
194
+
195
+ def add_ints(text: str, *, skip_port: bool = False) -> None:
196
+ for value in re.findall(r"\b\d+\b", text or ""):
197
+ try:
198
+ pid = int(value)
199
+ except ValueError:
200
+ continue
201
+ if skip_port and pid == port:
202
+ continue
203
+ pids.add(pid)
204
+
205
+ commands: list[tuple[list[str], str]] = [
206
+ (["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], "plain"),
207
+ (["fuser", "-n", "tcp", str(port)], "fuser"),
208
+ (["ss", "-ltnp", f"sport = :{port}"], "ss"),
209
+ (["netstat", "-ltnp"], "netstat"),
210
+ ]
211
+ for cmd, kind in commands:
212
+ if not shutil.which(cmd[0]):
213
+ continue
214
+ try:
215
+ proc = subprocess.run(
216
+ cmd,
217
+ text=True,
218
+ stdout=subprocess.PIPE,
219
+ stderr=subprocess.PIPE,
220
+ timeout=5,
221
+ )
222
+ except Exception:
223
+ continue
224
+ text = (proc.stdout or "") + "\n" + (proc.stderr or "")
225
+ if kind == "plain":
226
+ add_ints(text)
227
+ elif kind == "fuser":
228
+ add_ints(text.split(":", 1)[1] if ":" in text else text, skip_port=True)
229
+ elif kind == "ss":
230
+ for value in re.findall(r"pid=(\d+)", text):
231
+ add_ints(value)
232
+ else:
233
+ for line in text.splitlines():
234
+ if "LISTEN" not in line or f":{port}" not in line:
235
+ continue
236
+ match = re.search(r"\s(\d+)/", line)
237
+ if match:
238
+ add_ints(match.group(1))
239
+ return sorted(pid for pid in pids if pid not in (os.getpid(), os.getppid()))
240
+
241
+
242
+ @dataclass(frozen=True)
243
+ class ProcessQueryServices:
244
+ run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run
245
+ username: Callable[[], str] = getpass.getuser
246
+ current_pid: Callable[[], int] = os.getpid
247
+ parent_pid: Callable[[], int] = os.getppid
248
+
249
+
250
+ @dataclass(frozen=True)
251
+ class ProcessSignalServices:
252
+ kill: Callable[[int, int], None]
253
+ pid_is_running: Callable[[int], bool]
254
+ now: Callable[[], float] = time.time
255
+ sleep: Callable[[float], None] = time.sleep
256
+
257
+
258
+ @dataclass(frozen=True)
259
+ class ProcessControlServices:
260
+ query: ProcessQueryServices
261
+ signals: ProcessSignalServices
262
+ log: Callable[[str, str], None]
263
+ output: Callable[[str], None] = print
264
+
265
+
266
+ class ProcessTreeController:
267
+ """Inspect and terminate one process tree through explicit query/signal ports."""
268
+
269
+ def __init__(self, services: ProcessControlServices, *, platform_name: str = os.name) -> None:
270
+ self._services = services
271
+ self._platform_name = platform_name
272
+
273
+ def terminate_pid(self, pid: int, label: str, *, quiet: bool = False) -> bool:
274
+ if not self._services.signals.pid_is_running(pid):
275
+ return False
276
+
277
+ try:
278
+ if self._platform_name == "nt":
279
+ self._services.query.run(
280
+ ["taskkill", "/PID", str(pid), "/T", "/F"],
281
+ stdout=subprocess.DEVNULL,
282
+ stderr=subprocess.DEVNULL,
283
+ timeout=8,
284
+ )
285
+ else:
286
+ self._services.signals.kill(pid, signal.SIGTERM)
287
+ deadline = self._services.signals.now() + 4
288
+ while deadline > self._services.signals.now() and self._services.signals.pid_is_running(pid):
289
+ self._services.signals.sleep(0.1)
290
+ if self._services.signals.pid_is_running(pid):
291
+ self._services.signals.kill(pid, signal.SIGKILL)
292
+ if not quiet:
293
+ self._services.output(f"Stopped existing {label} session (pid {pid}).")
294
+ return True
295
+ except (OSError, subprocess.SubprocessError) as exc:
296
+ self._services.log(
297
+ "WARN",
298
+ f"process_tree_terminate_failed label={label!r} pid={pid} error={type(exc).__name__}: {exc}",
299
+ )
300
+ if not quiet:
301
+ self._services.output(f"Could not stop existing {label} session ({type(exc).__name__}).")
302
+ return False
303
+
304
+ def terminate_port(
305
+ self,
306
+ port: int,
307
+ label: str,
308
+ *,
309
+ quiet: bool = False,
310
+ pids_on_port: Callable[[int], list[int]],
311
+ stopped_noun: str = "listener(s)",
312
+ ) -> bool:
313
+ pids = pids_on_port(port)
314
+ protected = {
315
+ self._services.query.current_pid(),
316
+ self._services.query.parent_pid(),
317
+ }
318
+ stopped = False
319
+ for pid in pids:
320
+ if pid in protected:
321
+ continue
322
+ stopped = self.terminate_pid(pid, label, quiet=True) or stopped
323
+ if stopped and not quiet:
324
+ self._services.output(
325
+ f"Stopped existing {label} {stopped_noun}: "
326
+ f"{', '.join(map(str, pids))}."
327
+ )
328
+ return stopped
329
+
330
+ def descendant_pids(self, pid: int) -> list[int]:
331
+ if pid <= 0 or self._platform_name == "nt":
332
+ return []
333
+ try:
334
+ result = self._services.query.run(
335
+ ["ps", "-eo", "pid=,ppid="],
336
+ text=True,
337
+ stdout=subprocess.PIPE,
338
+ stderr=subprocess.DEVNULL,
339
+ timeout=5,
340
+ )
341
+ except (OSError, subprocess.SubprocessError) as exc:
342
+ self._services.log("WARN", f"process_tree_query_failed pid={pid} error={type(exc).__name__}: {exc}")
343
+ return []
344
+ children: dict[int, list[int]] = {}
345
+ for line in result.stdout.splitlines():
346
+ parts = line.strip().split()
347
+ if len(parts) < 2:
348
+ continue
349
+ try:
350
+ child, parent = int(parts[0]), int(parts[1])
351
+ except ValueError:
352
+ continue
353
+ children.setdefault(parent, []).append(child)
354
+ descendants: list[int] = []
355
+ stack = list(children.get(pid, []))
356
+ while stack:
357
+ child = stack.pop()
358
+ if child in descendants:
359
+ continue
360
+ descendants.append(child)
361
+ stack.extend(children.get(child, []))
362
+ return descendants
363
+
364
+ def parent_pid_and_command(self, pid: int) -> tuple[int, str] | None:
365
+ if pid <= 0 or self._platform_name == "nt":
366
+ return None
367
+ try:
368
+ result = self._services.query.run(
369
+ ["ps", "-p", str(pid), "-o", "ppid=,command="],
370
+ text=True,
371
+ stdout=subprocess.PIPE,
372
+ stderr=subprocess.DEVNULL,
373
+ timeout=5,
374
+ )
375
+ except (OSError, subprocess.SubprocessError) as exc:
376
+ self._services.log("WARN", f"process_parent_query_failed pid={pid} error={type(exc).__name__}: {exc}")
377
+ return None
378
+ parts = result.stdout.strip().split(maxsplit=1)
379
+ if not parts:
380
+ return None
381
+ try:
382
+ parent = int(parts[0])
383
+ except ValueError:
384
+ return None
385
+ return parent, parts[1] if len(parts) > 1 else ""
386
+
387
+ def client_wrapper_parent_pids(self, pid: int) -> list[int]:
388
+ wrappers: list[int] = []
389
+ current = pid
390
+ protected = {self._services.query.current_pid(), self._services.query.parent_pid()}
391
+ for _ in range(4):
392
+ parent_info = self.parent_pid_and_command(current)
393
+ if parent_info is None:
394
+ break
395
+ parent, command = parent_info
396
+ if parent <= 0 or parent in protected:
397
+ break
398
+ if "ciel-runtime" not in command and "ciel_runtime.py" not in command:
399
+ break
400
+ if " serve" in command or " mcp-proxy" in command:
401
+ break
402
+ wrappers.append(parent)
403
+ current = parent
404
+ return wrappers
405
+
406
+ def terminate_tree(self, pid: int, label: str, *, quiet: bool = False) -> bool:
407
+ if pid <= 0:
408
+ return False
409
+ if self._platform_name == "nt":
410
+ return self.terminate_pid(pid, label, quiet=quiet)
411
+ protected = {self._services.query.current_pid(), self._services.query.parent_pid()}
412
+ targets = [item for item in [pid, *self.descendant_pids(pid)] if item > 0 and item not in protected]
413
+ if not targets:
414
+ return False
415
+ stopped = False
416
+ for target in targets:
417
+ try:
418
+ if self._services.signals.pid_is_running(target):
419
+ self._services.signals.kill(target, signal.SIGTERM)
420
+ stopped = True
421
+ except OSError as exc:
422
+ self._services.log(
423
+ "WARN",
424
+ f"process_tree_signal_failed signal=TERM pid={target} error={type(exc).__name__}: {exc}",
425
+ )
426
+ deadline = self._services.signals.now() + 4
427
+ while self._services.signals.now() < deadline:
428
+ if not any(self._services.signals.pid_is_running(target) for target in targets):
429
+ break
430
+ self._services.signals.sleep(0.1)
431
+ for target in targets:
432
+ if not self._services.signals.pid_is_running(target):
433
+ continue
434
+ try:
435
+ self._services.signals.kill(target, signal.SIGKILL)
436
+ stopped = True
437
+ except OSError as exc:
438
+ self._services.log(
439
+ "WARN",
440
+ f"process_tree_signal_failed signal=KILL pid={target} error={type(exc).__name__}: {exc}",
441
+ )
442
+ if stopped and not quiet:
443
+ self._services.output(f"Stopped existing {label} session(s): {', '.join(map(str, targets))}.")
444
+ return stopped
445
+
446
+
447
+ @dataclass(frozen=True)
448
+ class ProcessInspectionServices:
449
+ run: Callable[..., subprocess.CompletedProcess[str]]
450
+ read_bytes: Callable[[Path], bytes]
451
+ readlink: Callable[[Path], str]
452
+ username: Callable[[], str]
453
+ log: Callable[[str, str], None]
454
+
455
+
456
+ def process_command_line(
457
+ pid: int,
458
+ services: ProcessInspectionServices,
459
+ *,
460
+ platform_name: str = os.name,
461
+ ) -> str:
462
+ if pid <= 0:
463
+ return ""
464
+ if platform_name == "nt":
465
+ script = (
466
+ "Get-CimInstance Win32_Process -Filter "
467
+ f'"ProcessId = {int(pid)}" | Select-Object -ExpandProperty CommandLine'
468
+ )
469
+ command = ["powershell", "-NoProfile", "-Command", script]
470
+ else:
471
+ command = ["ps", "-p", str(pid), "-o", "command="]
472
+ try:
473
+ result = services.run(
474
+ command,
475
+ text=True,
476
+ stdout=subprocess.PIPE,
477
+ stderr=subprocess.DEVNULL,
478
+ timeout=5,
479
+ )
480
+ except (OSError, subprocess.SubprocessError) as exc:
481
+ services.log(
482
+ "WARN",
483
+ f"process_command_line_query_failed pid={pid} platform={platform_name} "
484
+ f"error={type(exc).__name__}: {exc}",
485
+ )
486
+ return ""
487
+ return result.stdout.strip()
488
+
489
+
490
+ def process_environ_contains(
491
+ pid: int,
492
+ key: str,
493
+ value: str | None,
494
+ services: ProcessInspectionServices,
495
+ *,
496
+ platform_name: str = os.name,
497
+ ) -> bool:
498
+ if platform_name == "nt" or pid <= 0:
499
+ return False
500
+ path = Path("/proc") / str(pid) / "environ"
501
+ try:
502
+ data = services.read_bytes(path)
503
+ except (FileNotFoundError, ProcessLookupError, PermissionError):
504
+ return False
505
+ except OSError as exc:
506
+ services.log(
507
+ "WARN",
508
+ f"process_environ_query_failed pid={pid} error={type(exc).__name__}: {exc}",
509
+ )
510
+ return False
511
+ needle = f"{key}=".encode()
512
+ for item in data.split(b"\0"):
513
+ if not item.startswith(needle):
514
+ continue
515
+ if value is None:
516
+ return True
517
+ return item[len(needle) :].decode("utf-8", errors="replace") == value
518
+ return False
519
+
520
+
521
+ def process_cwd(
522
+ pid: int,
523
+ services: ProcessInspectionServices,
524
+ *,
525
+ platform_name: str = os.name,
526
+ ) -> Path | None:
527
+ if platform_name == "nt" or pid <= 0:
528
+ return None
529
+ path = Path("/proc") / str(pid) / "cwd"
530
+ try:
531
+ return Path(services.readlink(path)).resolve()
532
+ except (FileNotFoundError, ProcessLookupError, PermissionError):
533
+ return None
534
+ except OSError as exc:
535
+ services.log(
536
+ "WARN",
537
+ f"process_cwd_query_failed pid={pid} error={type(exc).__name__}: {exc}",
538
+ )
539
+ return None
540
+
541
+
542
+ def posix_process_rows(services: ProcessInspectionServices) -> list[tuple[int, str, str]]:
543
+ try:
544
+ result = services.run(
545
+ ["ps", "-u", services.username(), "-o", "pid=,stat=,command="],
546
+ text=True,
547
+ stdout=subprocess.PIPE,
548
+ stderr=subprocess.DEVNULL,
549
+ timeout=5,
550
+ )
551
+ except (OSError, subprocess.SubprocessError) as exc:
552
+ services.log(
553
+ "WARN",
554
+ f"process_list_query_failed platform=posix error={type(exc).__name__}: {exc}",
555
+ )
556
+ return []
557
+ rows: list[tuple[int, str, str]] = []
558
+ for line in result.stdout.splitlines():
559
+ parts = line.strip().split(maxsplit=2)
560
+ if len(parts) < 3:
561
+ continue
562
+ try:
563
+ pid = int(parts[0])
564
+ except ValueError:
565
+ continue
566
+ rows.append((pid, parts[1], parts[2]))
567
+ return rows
568
+
569
+
570
+ def terminate_matching_processes(
571
+ needles: list[str],
572
+ label: str,
573
+ services: ProcessControlServices,
574
+ *,
575
+ quiet: bool = False,
576
+ platform_name: str = os.name,
577
+ ) -> bool:
578
+ if platform_name == "nt":
579
+ matched = _windows_matching_processes(needles, services)
580
+ if matched is None:
581
+ return False
582
+ stopped = _terminate_windows_processes(matched, label, services)
583
+ else:
584
+ matched = _posix_matching_processes(needles, services)
585
+ if matched is None:
586
+ return False
587
+ stopped = _terminate_posix_processes(matched, label, services)
588
+ if stopped and not quiet:
589
+ services.output(f"Stopped existing {label} session(s): {', '.join(map(str, matched))}.")
590
+ return stopped
591
+
592
+
593
+ def _windows_matching_processes(
594
+ needles: list[str], services: ProcessControlServices
595
+ ) -> list[int] | None:
596
+ script = "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"
597
+ try:
598
+ result = services.query.run(
599
+ ["powershell", "-NoProfile", "-Command", script],
600
+ text=True,
601
+ stdout=subprocess.PIPE,
602
+ stderr=subprocess.DEVNULL,
603
+ timeout=8,
604
+ )
605
+ rows = json.loads(result.stdout or "[]")
606
+ except Exception as exc:
607
+ services.log("WARN", f"process_query_failed platform=windows error={type(exc).__name__}: {exc}")
608
+ return None
609
+ if isinstance(rows, dict):
610
+ rows = [rows]
611
+ matched: list[int] = []
612
+ for row in rows if isinstance(rows, list) else []:
613
+ try:
614
+ pid = int(row.get("ProcessId"))
615
+ except (AttributeError, TypeError, ValueError):
616
+ continue
617
+ command = str(row.get("CommandLine") or "")
618
+ if pid in {services.query.current_pid(), services.query.parent_pid()} or not command:
619
+ continue
620
+ if all(needle in command for needle in needles):
621
+ matched.append(pid)
622
+ return matched
623
+
624
+
625
+ def _terminate_windows_processes(
626
+ matched: list[int], label: str, services: ProcessControlServices
627
+ ) -> bool:
628
+ stopped = False
629
+ for pid in matched:
630
+ try:
631
+ services.query.run(
632
+ ["taskkill", "/PID", str(pid), "/T", "/F"],
633
+ stdout=subprocess.DEVNULL,
634
+ stderr=subprocess.DEVNULL,
635
+ timeout=8,
636
+ )
637
+ stopped = True
638
+ except Exception as exc:
639
+ services.log(
640
+ "WARN",
641
+ f"process_terminate_failed platform=windows label={label!r} pid={pid} "
642
+ f"error={type(exc).__name__}: {exc}",
643
+ )
644
+ return stopped
645
+
646
+
647
+ def _posix_matching_processes(
648
+ needles: list[str], services: ProcessControlServices
649
+ ) -> list[int] | None:
650
+ try:
651
+ result = services.query.run(
652
+ ["ps", "-u", services.query.username(), "-o", "pid=,stat=,command="],
653
+ text=True,
654
+ stdout=subprocess.PIPE,
655
+ stderr=subprocess.DEVNULL,
656
+ timeout=5,
657
+ )
658
+ except Exception as exc:
659
+ services.log("WARN", f"process_query_failed platform=posix error={type(exc).__name__}: {exc}")
660
+ return None
661
+ matched: list[int] = []
662
+ for line in result.stdout.splitlines():
663
+ parts = line.strip().split(maxsplit=2)
664
+ if len(parts) < 3:
665
+ continue
666
+ try:
667
+ pid = int(parts[0])
668
+ except ValueError:
669
+ continue
670
+ stat, command = parts[1], parts[2]
671
+ if pid in {services.query.current_pid(), services.query.parent_pid()} or stat.startswith("Z"):
672
+ continue
673
+ if all(needle in command for needle in needles):
674
+ matched.append(pid)
675
+ return matched
676
+
677
+
678
+ def _terminate_posix_processes(
679
+ matched: list[int], label: str, services: ProcessControlServices
680
+ ) -> bool:
681
+ stopped = False
682
+ for pid in matched:
683
+ try:
684
+ services.signals.kill(pid, signal.SIGTERM)
685
+ stopped = True
686
+ except Exception as exc:
687
+ services.log(
688
+ "WARN",
689
+ f"process_terminate_failed platform=posix signal=TERM label={label!r} pid={pid} "
690
+ f"error={type(exc).__name__}: {exc}",
691
+ )
692
+ deadline = services.signals.now() + 3
693
+ while services.signals.now() < deadline:
694
+ if not any(services.signals.pid_is_running(pid) for pid in matched):
695
+ break
696
+ services.signals.sleep(0.1)
697
+ for pid in matched:
698
+ if not services.signals.pid_is_running(pid):
699
+ continue
700
+ try:
701
+ services.signals.kill(pid, signal.SIGKILL)
702
+ except Exception as exc:
703
+ services.log(
704
+ "WARN",
705
+ f"process_terminate_failed platform=posix signal=KILL label={label!r} pid={pid} "
706
+ f"error={type(exc).__name__}: {exc}",
707
+ )
708
+ return stopped