@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,271 @@
1
+ """Codex child-process records, discovery, and shutdown lifecycle."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class CodexProcessPorts:
16
+ pid_running: Callable[[int], bool]
17
+ command_line: Callable[[int], str]
18
+ managed_process: Callable[[int, str | None], bool]
19
+ terminate_tree: Callable[[int, str, bool], bool]
20
+ process_rows: Callable[[], list[tuple[int, str, str]]]
21
+ process_cwd: Callable[[int], Path | None]
22
+ parent_info: Callable[[int], tuple[int, str] | None]
23
+ log: Callable[[str, str], None]
24
+ current_pid: Callable[[], int]
25
+ parent_pid: Callable[[], int]
26
+
27
+
28
+ class CodexProcessRepository:
29
+ def __init__(self, process_dir: Path, log: Callable[[str, str], None]) -> None:
30
+ self.process_dir = process_dir
31
+ self.log = log
32
+
33
+ def write(
34
+ self,
35
+ path: Path | None,
36
+ pid: int,
37
+ command: list[str],
38
+ cwd: Path | None = None,
39
+ ) -> None:
40
+ if path is None or pid <= 0:
41
+ return
42
+ try:
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ payload = {
45
+ "pid": int(pid),
46
+ "owner_pid": os.getpid(),
47
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
48
+ "cwd": str(cwd or Path.cwd()),
49
+ "cmd": [str(item) for item in command],
50
+ }
51
+ path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
52
+ try:
53
+ os.chmod(path, 0o600)
54
+ except Exception as exc:
55
+ self.log(
56
+ "WARN",
57
+ "codex_child_process_record_chmod_failed "
58
+ f"path={path} error={type(exc).__name__}: {exc}",
59
+ )
60
+ self.log("INFO", f"codex_child_process_registered pid={pid} path={path}")
61
+ except Exception as exc:
62
+ self.log(
63
+ "WARN",
64
+ f"codex_child_process_register_failed pid={pid} "
65
+ f"error={type(exc).__name__}: {exc}",
66
+ )
67
+
68
+ def release(self, path: Path | None, pid: int | None = None) -> None:
69
+ if path is None:
70
+ return
71
+ try:
72
+ if pid is not None and path.exists():
73
+ data = json.loads(path.read_text(encoding="utf-8"))
74
+ if int(data.get("pid") or 0) != int(pid):
75
+ return
76
+ path.unlink()
77
+ self.log("INFO", f"codex_child_process_released path={path}")
78
+ except FileNotFoundError:
79
+ return
80
+ except Exception as exc:
81
+ self.log(
82
+ "WARN",
83
+ f"codex_child_process_release_failed path={path} "
84
+ f"error={type(exc).__name__}: {exc}",
85
+ )
86
+
87
+ def records(self) -> list[tuple[Path, int]]:
88
+ if not self.process_dir.exists():
89
+ return []
90
+ records: list[tuple[Path, int]] = []
91
+ for path in sorted(self.process_dir.glob("*.json")):
92
+ try:
93
+ data = json.loads(path.read_text(encoding="utf-8"))
94
+ records.append((path, int(data.get("pid") or 0)))
95
+ except (
96
+ OSError,
97
+ UnicodeError,
98
+ json.JSONDecodeError,
99
+ AttributeError,
100
+ TypeError,
101
+ ValueError,
102
+ ) as exc:
103
+ self.log(
104
+ "WARN",
105
+ f"codex_tracked_process_record_invalid path={path} "
106
+ f"error={type(exc).__name__}: {exc}",
107
+ )
108
+ try:
109
+ path.unlink()
110
+ except FileNotFoundError:
111
+ pass
112
+ except OSError as unlink_exc:
113
+ self.log(
114
+ "WARN",
115
+ f"codex_tracked_process_record_cleanup_failed path={path} "
116
+ f"error={type(unlink_exc).__name__}: {unlink_exc}",
117
+ )
118
+ return records
119
+
120
+
121
+ def managed_process(
122
+ pid: int,
123
+ command: str | None,
124
+ *,
125
+ managed_environment: Callable[[int, str, str | None], bool],
126
+ command_line: Callable[[int], str],
127
+ ) -> bool:
128
+ if managed_environment(pid, "CIEL_RUNTIME_CODEX_MANAGED", "1"):
129
+ return True
130
+ text = (command if command is not None else command_line(pid)).lower()
131
+ return "codex" in text and ("--yolo" in text or "app-server" in text)
132
+
133
+
134
+ def terminate_recorded_child(
135
+ process: Any,
136
+ label: str,
137
+ *,
138
+ terminate_tree: Callable[[int, str, bool], bool],
139
+ log: Callable[[str, str], None],
140
+ ) -> None:
141
+ try:
142
+ pid = int(getattr(process, "pid", 0) or 0)
143
+ except (TypeError, ValueError) as exc:
144
+ log("WARN", f"codex_child_process_pid_invalid error={type(exc).__name__}: {exc}")
145
+ return
146
+ if pid <= 0:
147
+ return
148
+ try:
149
+ if process.poll() is not None:
150
+ return
151
+ except Exception as exc:
152
+ log(
153
+ "WARN",
154
+ f"codex_child_process_poll_failed pid={pid} error={type(exc).__name__}: {exc}",
155
+ )
156
+ terminate_tree(pid, label, True)
157
+ try:
158
+ process.wait(timeout=2)
159
+ except Exception as wait_exc:
160
+ log(
161
+ "WARN",
162
+ f"codex_child_process_wait_failed pid={pid} "
163
+ f"error={type(wait_exc).__name__}: {wait_exc}",
164
+ )
165
+ try:
166
+ process.kill()
167
+ except Exception as kill_exc:
168
+ log(
169
+ "WARN",
170
+ f"codex_child_process_kill_failed pid={pid} "
171
+ f"error={type(kill_exc).__name__}: {kill_exc}",
172
+ )
173
+ try:
174
+ process.wait(timeout=2)
175
+ except Exception as final_wait_exc:
176
+ log(
177
+ "WARN",
178
+ f"codex_child_process_final_wait_failed pid={pid} "
179
+ f"error={type(final_wait_exc).__name__}: {final_wait_exc}",
180
+ )
181
+
182
+
183
+ class CodexProcessLifecycle:
184
+ def __init__(self, repository: CodexProcessRepository, ports: CodexProcessPorts) -> None:
185
+ self.repository = repository
186
+ self.ports = ports
187
+
188
+ def terminate_tracked(self, reason: str, quiet: bool = True) -> bool:
189
+ stopped = False
190
+ for path, pid in self.repository.records():
191
+ if pid <= 0 or not self.ports.pid_running(pid):
192
+ self.repository.release(path, pid)
193
+ continue
194
+ command = self.ports.command_line(pid)
195
+ if not self.ports.managed_process(pid, command):
196
+ self.ports.log(
197
+ "WARN",
198
+ f"codex_tracked_process_skipped_unexpected_command pid={pid} path={path}",
199
+ )
200
+ self.repository.release(path, pid)
201
+ continue
202
+ stopped = self.ports.terminate_tree(pid, "previous Codex", quiet) or stopped
203
+ self.repository.release(path, pid)
204
+ self.ports.log(
205
+ "INFO",
206
+ f"codex_tracked_processes_terminated reason={reason} "
207
+ f"stopped={str(stopped).lower()}",
208
+ )
209
+ return stopped
210
+
211
+ def ancestor_pids(self, platform_name: str, limit: int = 12) -> set[int]:
212
+ ancestors = {self.ports.current_pid(), self.ports.parent_pid()}
213
+ if platform_name == "nt":
214
+ return ancestors
215
+ current = self.ports.current_pid()
216
+ for _ in range(max(0, limit)):
217
+ info = self.ports.parent_info(current)
218
+ if info is None:
219
+ break
220
+ parent, _command = info
221
+ if parent <= 0 or parent in ancestors:
222
+ break
223
+ ancestors.add(parent)
224
+ current = parent
225
+ return ancestors
226
+
227
+ def untracked_pids(
228
+ self,
229
+ cwd: Path | None,
230
+ *,
231
+ platform_name: str,
232
+ enabled: bool,
233
+ ) -> list[int]:
234
+ if platform_name == "nt" or not enabled:
235
+ return []
236
+ target_cwd = (cwd or Path.cwd()).resolve()
237
+ protected = self.ancestor_pids(platform_name)
238
+ matches = [
239
+ pid
240
+ for pid, stat, command in self.ports.process_rows()
241
+ if pid not in protected
242
+ and not stat.startswith("Z")
243
+ and self.ports.managed_process(pid, command)
244
+ and self.ports.process_cwd(pid) == target_cwd
245
+ ]
246
+ return sorted(set(matches))
247
+
248
+ def terminate_untracked(
249
+ self,
250
+ reason: str,
251
+ cwd: Path | None,
252
+ quiet: bool,
253
+ *,
254
+ platform_name: str,
255
+ enabled: bool,
256
+ ) -> bool:
257
+ pids = self.untracked_pids(cwd, platform_name=platform_name, enabled=enabled)
258
+ if not pids:
259
+ return False
260
+ stopped = False
261
+ for pid in pids:
262
+ stopped = (
263
+ self.ports.terminate_tree(pid, "previous untracked Codex", quiet)
264
+ or stopped
265
+ )
266
+ self.ports.log(
267
+ "WARN",
268
+ f"codex_untracked_processes_terminated reason={reason} "
269
+ f"pids={','.join(map(str, pids))} stopped={str(stopped).lower()}",
270
+ )
271
+ return stopped
@@ -2,11 +2,149 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
6
+ from dataclasses import dataclass
5
7
  from typing import Any, Callable
6
8
 
7
9
  from .agent_router import COMMON_RUNTIME_ROUTER_CAPABILITIES, RouterCapability
8
10
 
9
11
 
12
+ CODEX_CAPACITY_ERROR_CODES = frozenset({"server_is_overloaded", "slow_down"})
13
+ CODEX_RESPONSE_PREAMBLE_LIMIT = 256 * 1024
14
+ _CODEX_NON_OUTPUT_EVENT_TYPES = frozenset({"response.created", "response.in_progress"})
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CodexResponsePreamble:
19
+ payload: bytes
20
+ capacity_error_code: str | None = None
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class CodexChannelContextPorts:
25
+ to_anthropic: Callable[[dict[str, Any], str], dict[str, Any]]
26
+ inject_pending: Callable[[dict[str, Any]], dict[str, Any]]
27
+ inject_tool_results: Callable[[dict[str, Any]], dict[str, Any]]
28
+ content_to_text: Callable[[Any], str]
29
+
30
+
31
+ class CodexChannelContextProjector:
32
+ def __init__(self, ports: CodexChannelContextPorts) -> None:
33
+ self._ports = ports
34
+
35
+ @staticmethod
36
+ def input_items(value: Any) -> list[dict[str, Any]]:
37
+ if isinstance(value, str):
38
+ return [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": value}]}]
39
+ if isinstance(value, dict):
40
+ return [dict(value)]
41
+ if isinstance(value, list):
42
+ return [dict(item) for item in value if isinstance(item, dict)]
43
+ return []
44
+
45
+ @staticmethod
46
+ def message_item(role: str, text: str) -> dict[str, Any]:
47
+ role = role if role in {"user", "assistant", "system", "developer"} else "user"
48
+ text_type = "output_text" if role == "assistant" else "input_text"
49
+ return {"type": "message", "role": role, "content": [{"type": text_type, "text": text}]}
50
+
51
+ def project(self, body: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
52
+ delivery = self._ports.to_anthropic(body, str(body.get("model") or ""))
53
+ original_count = len(delivery.get("messages") or [])
54
+ delivery = self._ports.inject_pending(delivery)
55
+ delivery = self._ports.inject_tool_results(delivery)
56
+ messages = [message for message in delivery.get("messages") or [] if isinstance(message, dict)]
57
+ additions = messages[original_count:]
58
+ metadata = delivery.get("metadata") if isinstance(delivery.get("metadata"), dict) else {}
59
+ projected = dict(body)
60
+ projected.pop("metadata", None)
61
+ if not additions and not metadata:
62
+ return projected, delivery
63
+ input_items = self.input_items(body.get("input", []))
64
+ for message in additions:
65
+ text = self._ports.content_to_text(message.get("content"))
66
+ if text.strip():
67
+ input_items.append(self.message_item(str(message.get("role") or "user"), text))
68
+ if input_items:
69
+ projected["input"] = input_items
70
+ return projected, delivery
71
+
72
+
73
+ def _codex_sse_event(block: bytes) -> dict[str, Any] | None:
74
+ text = block.decode("utf-8", errors="replace").strip()
75
+ if not text:
76
+ return None
77
+ data_lines = []
78
+ for line in text.splitlines():
79
+ if line.startswith("data:"):
80
+ data_lines.append(line[5:].lstrip())
81
+ candidate = "\n".join(data_lines).strip() if data_lines else text
82
+ try:
83
+ value = json.loads(candidate)
84
+ except (TypeError, ValueError):
85
+ return None
86
+ return value if isinstance(value, dict) else None
87
+
88
+
89
+ def _codex_capacity_error_code(event: dict[str, Any]) -> str | None:
90
+ if event.get("type") != "response.failed":
91
+ return None
92
+ response = event.get("response")
93
+ if not isinstance(response, dict):
94
+ return None
95
+ error = response.get("error")
96
+ if not isinstance(error, dict):
97
+ return None
98
+ code = str(error.get("code") or "").strip()
99
+ return code if code in CODEX_CAPACITY_ERROR_CODES else None
100
+
101
+
102
+ def read_codex_response_preamble(
103
+ stream: Any,
104
+ *,
105
+ max_bytes: int = CODEX_RESPONSE_PREAMBLE_LIMIT,
106
+ ) -> CodexResponsePreamble:
107
+ """Read control-only SSE events until output starts or a safe retry is known."""
108
+ buffered = bytearray()
109
+ while len(buffered) < max_bytes:
110
+ block = bytearray()
111
+ reached_eof = False
112
+ while len(buffered) + len(block) < max_bytes:
113
+ remaining = max_bytes - len(buffered) - len(block)
114
+ line = stream.readline(remaining + 1)
115
+ if not line:
116
+ reached_eof = True
117
+ break
118
+ block.extend(line)
119
+ if line in (b"\n", b"\r\n"):
120
+ break
121
+ if len(block) > remaining:
122
+ break
123
+ if not block:
124
+ return CodexResponsePreamble(bytes(buffered))
125
+ buffered.extend(block)
126
+ if len(buffered) >= max_bytes:
127
+ return CodexResponsePreamble(bytes(buffered))
128
+
129
+ event = _codex_sse_event(bytes(block))
130
+ if event is None:
131
+ # SSE comments and keepalives carry no model output.
132
+ meaningful = [line for line in block.splitlines() if line and not line.startswith(b":")]
133
+ if not meaningful and not reached_eof:
134
+ continue
135
+ return CodexResponsePreamble(bytes(buffered))
136
+
137
+ capacity_code = _codex_capacity_error_code(event)
138
+ if capacity_code:
139
+ return CodexResponsePreamble(bytes(buffered), capacity_code)
140
+ if event.get("type") not in _CODEX_NON_OUTPUT_EVENT_TYPES:
141
+ return CodexResponsePreamble(bytes(buffered))
142
+ if reached_eof:
143
+ return CodexResponsePreamble(bytes(buffered))
144
+
145
+ return CodexResponsePreamble(bytes(buffered))
146
+
147
+
10
148
  class CodexRouter:
11
149
  name = "codex"
12
150
  runtime = "codex"
@@ -21,6 +159,7 @@ class CodexRouter:
21
159
  ("pending_delivery_ack", "Injected channel cursors are committed after successful delivery."),
22
160
  ("request_observability", "Responses requests are traced and published to the runtime event bus."),
23
161
  ("upstream_error_mapping", "Upstream HTTP and client disconnect errors are mapped for Codex."),
162
+ ("capacity_retry", "Capacity-only failures are retried before any output or tool call is delivered."),
24
163
  ("backend_passthrough", "Non-responses Codex backend endpoints are passed through."),
25
164
  ("legacy_responses", "The legacy /v1/responses path remains supported."),
26
165
  )
@@ -77,4 +216,11 @@ class CodexRouter:
77
216
  assert all(any(capability.name == required for capability in CodexRouter.capabilities) for required in COMMON_RUNTIME_ROUTER_CAPABILITIES)
78
217
 
79
218
 
80
- __all__ = ["CodexRouter"]
219
+ __all__ = [
220
+ "CODEX_CAPACITY_ERROR_CODES",
221
+ "CodexChannelContextPorts",
222
+ "CodexChannelContextProjector",
223
+ "CodexResponsePreamble",
224
+ "CodexRouter",
225
+ "read_codex_response_preamble",
226
+ ]
@@ -0,0 +1,115 @@
1
+ """Read-only repository for the Codex local resume index."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import re
8
+ import sqlite3
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ciel_runtime_support.codex_config import (
14
+ codex_config_paths_for_launch,
15
+ toml_scalar_without_comment,
16
+ unquote_toml_string,
17
+ )
18
+
19
+
20
+ def codex_sqlite_home(
21
+ passthrough: list[str] | None = None,
22
+ env: dict[str, str] | None = None,
23
+ cwd: Path | None = None,
24
+ ) -> Path:
25
+ launch_env = env or os.environ
26
+ launch_cwd = (cwd or Path.cwd()).resolve()
27
+ configured: str | None = None
28
+ for path in codex_config_paths_for_launch(
29
+ passthrough or [], env=launch_env, cwd=launch_cwd
30
+ ):
31
+ try:
32
+ text = path.read_text(encoding="utf-8")
33
+ except OSError:
34
+ continue
35
+ table = ""
36
+ for line in text.splitlines():
37
+ stripped = toml_scalar_without_comment(line)
38
+ if not stripped:
39
+ continue
40
+ table_match = re.fullmatch(r"\[(.+)\]", stripped)
41
+ if table_match:
42
+ table = table_match.group(1).strip()
43
+ continue
44
+ if table:
45
+ continue
46
+ match = re.match(r"sqlite_home\s*=\s*(.+)$", stripped)
47
+ if match:
48
+ configured = unquote_toml_string(match.group(1))
49
+ raw = configured or str(launch_env.get("CODEX_SQLITE_HOME") or "").strip()
50
+ if raw:
51
+ path = Path(os.path.expandvars(raw)).expanduser()
52
+ return path if path.is_absolute() else launch_cwd / path
53
+ return Path(launch_env.get("CODEX_HOME") or (Path.home() / ".codex")).expanduser()
54
+
55
+
56
+ class CodexSessionRepository:
57
+ def __init__(self, database: Path, log: Callable[[str, str], None]) -> None:
58
+ self.database = database
59
+ self.log = log
60
+
61
+ def resumable(
62
+ self,
63
+ limit: int = 200,
64
+ *,
65
+ include_non_interactive: bool = False,
66
+ cwd: Path | None = None,
67
+ ) -> list[dict[str, Any]]:
68
+ if not self.database.is_file():
69
+ return []
70
+ sources = ["cli", "vscode"]
71
+ if include_non_interactive:
72
+ sources.extend(["exec", "app-server"])
73
+ placeholders = ", ".join("?" for _ in sources)
74
+ where = f"archived = 0 AND source IN ({placeholders})"
75
+ parameters: list[Any] = list(sources)
76
+ if cwd is not None:
77
+ normalized_cwd = str(cwd.resolve()).replace("\\", "/").rstrip("/")
78
+ slash_cwd = "REPLACE(cwd, CHAR(92), '/')"
79
+ portable_cwd = (
80
+ f"CASE WHEN {slash_cwd} LIKE '//?/UNC/%' "
81
+ f"THEN '//' || SUBSTR({slash_cwd}, 9) "
82
+ f"WHEN {slash_cwd} LIKE '//?/%' THEN SUBSTR({slash_cwd}, 5) "
83
+ f"ELSE {slash_cwd} END"
84
+ )
85
+ cwd_expression = f"RTRIM(({portable_cwd}), '/')"
86
+ if os.name == "nt":
87
+ cwd_expression = f"LOWER({cwd_expression})"
88
+ normalized_cwd = normalized_cwd.lower()
89
+ where += f" AND {cwd_expression} = ?"
90
+ parameters.append(normalized_cwd)
91
+ uri = self.database.resolve().as_uri() + "?mode=ro"
92
+ try:
93
+ with contextlib.closing(
94
+ sqlite3.connect(uri, uri=True, timeout=1.0)
95
+ ) as connection:
96
+ connection.row_factory = sqlite3.Row
97
+ rows = connection.execute(
98
+ f"""
99
+ SELECT id, title, first_user_message, cwd, model_provider,
100
+ COALESCE(NULLIF(updated_at_ms, 0), updated_at * 1000) AS activity_ms
101
+ FROM threads
102
+ WHERE {where}
103
+ ORDER BY COALESCE(NULLIF(recency_at_ms, 0),
104
+ NULLIF(updated_at_ms, 0), updated_at * 1000) DESC
105
+ LIMIT ?
106
+ """,
107
+ (*parameters, max(1, min(1000, int(limit)))),
108
+ ).fetchall()
109
+ except (OSError, sqlite3.Error) as exc:
110
+ self.log(
111
+ "WARN",
112
+ f"codex_resume_index_read_failed error={type(exc).__name__}: {exc}",
113
+ )
114
+ return []
115
+ return [dict(row) for row in rows]
@@ -0,0 +1,114 @@
1
+ """Codex resume-session query, presentation, and selection workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class CodexSessionRepositoryPorts:
15
+ sqlite_home: Callable[..., Path]
16
+ resumable: Callable[[Path, int, bool, Path | None], list[dict[str, Any]]]
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class CodexSessionPresentationPorts:
21
+ select: Callable[..., int | None]
22
+ compact_text: Callable[[str, int], str]
23
+ output: Callable[[str], None]
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class CodexSessionSelectionService:
28
+ repository: CodexSessionRepositoryPorts
29
+ presentation: CodexSessionPresentationPorts
30
+
31
+ def sqlite_home_for_launch(
32
+ self,
33
+ passthrough: list[str] | None = None,
34
+ env: dict[str, str] | None = None,
35
+ cwd: Path | None = None,
36
+ ) -> Path:
37
+ return self.repository.sqlite_home(passthrough, env=env, cwd=cwd)
38
+
39
+ def local_resume_sessions(
40
+ self,
41
+ env: dict[str, str] | None = None,
42
+ limit: int = 200,
43
+ include_non_interactive: bool = False,
44
+ passthrough: list[str] | None = None,
45
+ cwd: Path | None = None,
46
+ session_cwd: Path | None = None,
47
+ ) -> list[dict[str, Any]]:
48
+ database = (
49
+ self.sqlite_home_for_launch(passthrough, env=env, cwd=cwd)
50
+ / "state_5.sqlite"
51
+ )
52
+ return self.repository.resumable(
53
+ database,
54
+ limit,
55
+ include_non_interactive,
56
+ session_cwd,
57
+ )
58
+
59
+ def resume_session_row(self, session: dict[str, Any]) -> str:
60
+ title = str(
61
+ session.get("title")
62
+ or session.get("first_user_message")
63
+ or "Untitled session"
64
+ ).strip()
65
+ title = re.sub(r"\s+", " ", title)
66
+ cwd = str(session.get("cwd") or "").strip()
67
+ folder = Path(cwd).name if cwd else "-"
68
+ provider = str(session.get("model_provider") or "-").strip()
69
+ try:
70
+ activity = datetime.fromtimestamp(
71
+ int(session.get("activity_ms") or 0) / 1000
72
+ ).strftime("%Y-%m-%d %H:%M")
73
+ except (OSError, OverflowError, TypeError, ValueError):
74
+ activity = "unknown time"
75
+ title = self.presentation.compact_text(title, 66)
76
+ return f"{title} [{folder} | {provider} | {activity}]"
77
+
78
+ def select_resume_session(
79
+ self,
80
+ env: dict[str, str] | None = None,
81
+ include_non_interactive: bool = False,
82
+ passthrough: list[str] | None = None,
83
+ cwd: Path | None = None,
84
+ ) -> str | None:
85
+ launch_cwd = (cwd or Path.cwd()).resolve()
86
+ show_all = "--all" in (passthrough or [])
87
+ sessions = self.local_resume_sessions(
88
+ env,
89
+ include_non_interactive=include_non_interactive,
90
+ passthrough=passthrough,
91
+ cwd=launch_cwd,
92
+ session_cwd=None if show_all else launch_cwd,
93
+ )
94
+ if not sessions:
95
+ database = (
96
+ self.sqlite_home_for_launch(
97
+ passthrough, env=env, cwd=launch_cwd
98
+ ) / "state_5.sqlite"
99
+ )
100
+ scope = "" if show_all else f" for the current directory ({launch_cwd})"
101
+ hint = "" if show_all else " Use `codex resume --all` to show every session."
102
+ self.presentation.output(
103
+ f"Ciel Runtime could not find resumable Codex sessions{scope} in: "
104
+ f"{database}.{hint}"
105
+ )
106
+ return None
107
+ selected = self.presentation.select(
108
+ "Resume Codex session",
109
+ [self.resume_session_row(session) for session in sessions],
110
+ footer="Up/Down moves. Enter resumes. Esc/q cancels.",
111
+ )
112
+ if selected is None:
113
+ return ""
114
+ return str(sessions[selected].get("id") or "").strip()