@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,414 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from dataclasses import dataclass
5
+ import json
6
+ from typing import Any, Callable, Iterator
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ChannelWakeTranscriptServices:
11
+ claim_prompt: Callable[[int], str]
12
+ prompt_references_message_id: Callable[..., bool]
13
+ prompt_message_ids: Callable[[str], set[int]]
14
+ now: Callable[[], float]
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class ChannelWakeStateReaderPorts:
19
+ latest_transcript: Callable[[], Any]
20
+ read_tail_text: Callable[[Any], str]
21
+ wake_state_from_text: Callable[..., str]
22
+ queued_age_from_text: Callable[..., float | None]
23
+ stale_seconds: Callable[[], float]
24
+
25
+
26
+ class ChannelWakeStateReader:
27
+ def __init__(self, ports: ChannelWakeStateReaderPorts) -> None:
28
+ self._ports = ports
29
+
30
+ @staticmethod
31
+ def message_id(message: dict[str, Any]) -> int:
32
+ try:
33
+ return int(message.get("id") or 0)
34
+ except (TypeError, ValueError):
35
+ return 0
36
+
37
+ @staticmethod
38
+ def prompt_candidates(
39
+ message: dict[str, Any], prompt: str | None
40
+ ) -> tuple[str, ...]:
41
+ candidates = [prompt] if prompt else []
42
+ body = str(
43
+ message.get("message") if message.get("message") is not None else ""
44
+ )
45
+ if body:
46
+ candidates.append(body)
47
+ return tuple(candidates)
48
+
49
+ def state(self, message_id: int) -> str:
50
+ if message_id <= 0:
51
+ return "completed"
52
+ text = self._latest_text()
53
+ return self._ports.wake_state_from_text(message_id, text) if text else "unknown"
54
+
55
+ def state_for_message(
56
+ self, message: dict[str, Any], prompt: str | None = None
57
+ ) -> str:
58
+ message_id = self.message_id(message)
59
+ if message_id <= 0:
60
+ return "completed"
61
+ text = self._latest_text()
62
+ if not text:
63
+ return "unknown"
64
+ return self._ports.wake_state_from_text(
65
+ message_id, text, self.prompt_candidates(message, prompt)
66
+ )
67
+
68
+ def queued_is_stale(
69
+ self, message: dict[str, Any], prompt: str | None = None
70
+ ) -> bool:
71
+ message_id = self.message_id(message)
72
+ if message_id <= 0:
73
+ return False
74
+ text = self._latest_text()
75
+ if not text:
76
+ return False
77
+ age = self._ports.queued_age_from_text(
78
+ message_id, text, self.prompt_candidates(message, prompt)
79
+ )
80
+ return age is not None and age >= self._ports.stale_seconds()
81
+
82
+ def _latest_text(self) -> str:
83
+ path = self._ports.latest_transcript()
84
+ return self._ports.read_tail_text(path) if path is not None else ""
85
+
86
+
87
+ def record_timestamp_seconds(record: dict[str, Any]) -> float | None:
88
+ raw = record.get("timestamp")
89
+ if raw is None and isinstance(record.get("attachment"), dict):
90
+ raw = record["attachment"].get("timestamp")
91
+ if raw is None:
92
+ return None
93
+ if isinstance(raw, (int, float)):
94
+ return float(raw)
95
+ text = str(raw or "").strip()
96
+ if not text:
97
+ return None
98
+ try:
99
+ return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
100
+ except (TypeError, ValueError):
101
+ return None
102
+
103
+
104
+ def content_text(value: Any) -> str:
105
+ if isinstance(value, str):
106
+ return value
107
+ if isinstance(value, dict):
108
+ parts: list[str] = []
109
+ for key in ("text", "content", "input_text", "output_text", "message"):
110
+ raw = value.get(key)
111
+ if isinstance(raw, str):
112
+ parts.append(raw)
113
+ elif isinstance(raw, (dict, list)):
114
+ nested = content_text(raw)
115
+ if nested:
116
+ parts.append(nested)
117
+ return "\n".join(parts)
118
+ if isinstance(value, list):
119
+ parts = []
120
+ for item in value:
121
+ nested = content_text(item)
122
+ if nested:
123
+ parts.append(nested)
124
+ return "\n".join(parts)
125
+ return ""
126
+
127
+
128
+ def user_text(record: dict[str, Any]) -> str:
129
+ record_type = str(record.get("type") or "")
130
+ message = record.get("message")
131
+ message_obj = message if isinstance(message, dict) else {}
132
+ if record_type == "user" or str(message_obj.get("role") or "") == "user":
133
+ return content_text(message_obj.get("content"))
134
+ payload = record.get("payload")
135
+ payload_obj = payload if isinstance(payload, dict) else {}
136
+ payload_type = str(payload_obj.get("type") or "")
137
+ payload_role = str(payload_obj.get("role") or "")
138
+ if record_type == "response_item" and payload_type == "message" and payload_role == "user":
139
+ return content_text(payload_obj.get("content"))
140
+ if record_type == "event_msg" and payload_type == "user_message":
141
+ return content_text(payload_obj.get("message"))
142
+ return ""
143
+
144
+
145
+ def is_assistant_message(record: dict[str, Any]) -> bool:
146
+ record_type = str(record.get("type") or "")
147
+ message = record.get("message")
148
+ message_obj = message if isinstance(message, dict) else {}
149
+ message_role = str(message_obj.get("role") or "")
150
+ if (
151
+ record_type == "assistant"
152
+ or message_role == "assistant"
153
+ or str(record.get("subtype") or "") == "turn_duration"
154
+ ):
155
+ return True
156
+ payload = record.get("payload")
157
+ payload_obj = payload if isinstance(payload, dict) else {}
158
+ return (
159
+ record_type == "response_item"
160
+ and str(payload_obj.get("type") or "") == "message"
161
+ and str(payload_obj.get("role") or "") == "assistant"
162
+ )
163
+
164
+
165
+ def tool_call_id(value: dict[str, Any]) -> str:
166
+ for key in ("call_id", "id", "tool_call_id"):
167
+ raw = value.get(key)
168
+ if isinstance(raw, str) and raw.strip():
169
+ return raw.strip()
170
+ return ""
171
+
172
+
173
+ def message_content_blocks(message: dict[str, Any]) -> list[dict[str, Any]]:
174
+ content = message.get("content")
175
+ if isinstance(content, dict):
176
+ return [content]
177
+ if isinstance(content, list):
178
+ return [item for item in content if isinstance(item, dict)]
179
+ return []
180
+
181
+
182
+ def tool_use_ids(message: dict[str, Any]) -> set[str]:
183
+ return {
184
+ str(block.get("id") or "").strip()
185
+ for block in message_content_blocks(message)
186
+ if block.get("type") == "tool_use" and str(block.get("id") or "").strip()
187
+ }
188
+
189
+
190
+ def tool_result_ids(message: dict[str, Any]) -> set[str]:
191
+ return {
192
+ str(block.get("tool_use_id") or "").strip()
193
+ for block in message_content_blocks(message)
194
+ if block.get("type") == "tool_result" and str(block.get("tool_use_id") or "").strip()
195
+ }
196
+
197
+
198
+ def active_tool_call_from_text(text: str) -> bool:
199
+ pending_tool_ids: set[str] = set()
200
+ unknown_tool_active = False
201
+ for raw_line in text.splitlines():
202
+ try:
203
+ record = json.loads(raw_line)
204
+ except (TypeError, ValueError):
205
+ continue
206
+ if not isinstance(record, dict):
207
+ continue
208
+ record_type = str(record.get("type") or "")
209
+ message = record.get("message")
210
+ message_obj = message if isinstance(message, dict) else {}
211
+ message_role = str(message_obj.get("role") or "")
212
+ payload = record.get("payload")
213
+ payload_obj = payload if isinstance(payload, dict) else {}
214
+ payload_type = str(payload_obj.get("type") or "")
215
+ if record_type == "response_item":
216
+ if payload_type in {"function_call", "custom_tool_call", "local_shell_call"}:
217
+ call_id = tool_call_id(payload_obj)
218
+ if call_id:
219
+ pending_tool_ids.add(call_id)
220
+ else:
221
+ unknown_tool_active = True
222
+ continue
223
+ if payload_type in {"function_call_output", "custom_tool_call_output", "local_shell_call_output"}:
224
+ call_id = tool_call_id(payload_obj)
225
+ if call_id:
226
+ pending_tool_ids.discard(call_id)
227
+ else:
228
+ pending_tool_ids.clear()
229
+ unknown_tool_active = False
230
+ continue
231
+ if is_assistant_message(record):
232
+ pending_tool_ids.clear()
233
+ unknown_tool_active = False
234
+ continue
235
+ if record_type == "event_msg":
236
+ if payload_type in {"mcp_tool_call_begin", "tool_call_begin"}:
237
+ call_id = tool_call_id(payload_obj)
238
+ if call_id:
239
+ pending_tool_ids.add(call_id)
240
+ else:
241
+ unknown_tool_active = True
242
+ continue
243
+ if payload_type in {"mcp_tool_call_end", "tool_call_end"}:
244
+ call_id = tool_call_id(payload_obj)
245
+ if call_id:
246
+ pending_tool_ids.discard(call_id)
247
+ else:
248
+ pending_tool_ids.clear()
249
+ unknown_tool_active = False
250
+ continue
251
+ if record_type == "assistant" or message_role == "assistant":
252
+ use_ids = tool_use_ids(message_obj)
253
+ if str(message_obj.get("stop_reason") or "") == "tool_use" or use_ids:
254
+ pending_tool_ids.update(use_ids)
255
+ if not use_ids:
256
+ unknown_tool_active = True
257
+ else:
258
+ pending_tool_ids.clear()
259
+ unknown_tool_active = False
260
+ continue
261
+ if record_type == "user" or message_role == "user":
262
+ result_ids = tool_result_ids(message_obj)
263
+ if result_ids:
264
+ pending_tool_ids.difference_update(result_ids)
265
+ unknown_tool_active = False
266
+ elif record.get("toolUseResult") is not None:
267
+ pending_tool_ids.clear()
268
+ unknown_tool_active = False
269
+ return bool(pending_tool_ids or unknown_tool_active)
270
+
271
+
272
+ def active_turn_from_text(text: str) -> bool:
273
+ active = False
274
+ for raw_line in text.splitlines():
275
+ try:
276
+ record = json.loads(raw_line)
277
+ except (TypeError, ValueError):
278
+ continue
279
+ if not isinstance(record, dict):
280
+ continue
281
+ payload = record.get("payload")
282
+ payload_obj = payload if isinstance(payload, dict) else {}
283
+ event_type = str(payload_obj.get("type") or record.get("type") or "")
284
+ if event_type in {"task_started", "turn_started"}:
285
+ active = True
286
+ elif event_type in {"task_complete", "turn_complete", "turn_aborted"}:
287
+ active = False
288
+ return active
289
+
290
+
291
+ def queued_age_seconds_from_text(
292
+ message_id: int,
293
+ text: str,
294
+ prompt_texts: list[str] | tuple[str, ...] | None,
295
+ services: ChannelWakeTranscriptServices,
296
+ *,
297
+ now: float | None = None,
298
+ ) -> float | None:
299
+ if message_id <= 0:
300
+ return None
301
+ prompts = [str(item) for item in (prompt_texts or ()) if str(item or "").strip()]
302
+ claimed = services.claim_prompt(message_id)
303
+ if claimed:
304
+ prompts.append(claimed)
305
+ latest_queued_at: float | None = None
306
+ for record in _jsonl_records(text):
307
+ record_type = str(record.get("type") or "")
308
+ candidate = ""
309
+ if record_type == "queue-operation" and record.get("operation") == "enqueue":
310
+ raw = record.get("content")
311
+ candidate = raw if isinstance(raw, str) else ""
312
+ elif record_type == "attachment":
313
+ attachment = record.get("attachment")
314
+ if isinstance(attachment, dict) and attachment.get("type") == "queued_command":
315
+ raw = attachment.get("prompt")
316
+ candidate = raw if isinstance(raw, str) else ""
317
+ actual_user_text = user_text(record)
318
+ if actual_user_text and services.prompt_references_message_id(
319
+ actual_user_text, message_id, prompts
320
+ ):
321
+ return None
322
+ if not candidate or not services.prompt_references_message_id(candidate, message_id, prompts):
323
+ continue
324
+ timestamp = record_timestamp_seconds(record)
325
+ if timestamp is not None:
326
+ latest_queued_at = max(latest_queued_at or 0.0, timestamp)
327
+ if latest_queued_at is None:
328
+ return None
329
+ current = services.now() if now is None else float(now)
330
+ return max(0.0, current - latest_queued_at)
331
+
332
+
333
+ def wake_state_from_text(
334
+ message_id: int,
335
+ text: str,
336
+ prompt_texts: list[str] | tuple[str, ...] | None,
337
+ services: ChannelWakeTranscriptServices,
338
+ ) -> str:
339
+ if message_id <= 0:
340
+ return "completed"
341
+ prompts = [str(item) for item in (prompt_texts or ()) if str(item or "").strip()]
342
+ claimed = services.claim_prompt(message_id)
343
+ if claimed:
344
+ prompts.append(claimed)
345
+ seen_queued_prompt = False
346
+ seen_real_prompt = False
347
+ for record in _jsonl_records(text):
348
+ record_type = str(record.get("type") or "")
349
+ if seen_real_prompt and is_assistant_message(record):
350
+ return "completed"
351
+ if record_type == "queue-operation" and record.get("operation") == "enqueue":
352
+ raw = record.get("content")
353
+ if isinstance(raw, str) and services.prompt_references_message_id(raw, message_id, prompts):
354
+ seen_queued_prompt = True
355
+ continue
356
+ if record_type == "attachment":
357
+ attachment = record.get("attachment")
358
+ if isinstance(attachment, dict) and attachment.get("type") == "queued_command":
359
+ raw = attachment.get("prompt")
360
+ if isinstance(raw, str) and services.prompt_references_message_id(raw, message_id, prompts):
361
+ seen_queued_prompt = True
362
+ continue
363
+ if services.prompt_references_message_id(user_text(record), message_id, prompts):
364
+ seen_real_prompt = True
365
+ if seen_real_prompt:
366
+ return "pending"
367
+ return "queued" if seen_queued_prompt else "missing"
368
+
369
+
370
+ def queued_command_ids_from_text(
371
+ text: str, services: ChannelWakeTranscriptServices
372
+ ) -> set[int]:
373
+ ids: set[int] = set()
374
+ for record in _jsonl_records(text):
375
+ candidate = ""
376
+ if record.get("type") == "queue-operation" and record.get("operation") == "enqueue":
377
+ raw = record.get("content")
378
+ candidate = raw if isinstance(raw, str) else ""
379
+ elif record.get("type") == "attachment":
380
+ attachment = record.get("attachment")
381
+ if isinstance(attachment, dict) and attachment.get("type") == "queued_command":
382
+ raw = attachment.get("prompt")
383
+ candidate = raw if isinstance(raw, str) else ""
384
+ if candidate:
385
+ ids.update(services.prompt_message_ids(candidate))
386
+ return ids
387
+
388
+
389
+ def _jsonl_records(text: str) -> Iterator[dict[str, Any]]:
390
+ for raw_line in text.splitlines():
391
+ try:
392
+ record = json.loads(raw_line)
393
+ except (TypeError, ValueError):
394
+ continue
395
+ if isinstance(record, dict):
396
+ yield record
397
+
398
+
399
+ __all__ = [
400
+ "active_tool_call_from_text",
401
+ "active_turn_from_text",
402
+ "ChannelWakeTranscriptServices",
403
+ "content_text",
404
+ "is_assistant_message",
405
+ "message_content_blocks",
406
+ "record_timestamp_seconds",
407
+ "queued_age_seconds_from_text",
408
+ "queued_command_ids_from_text",
409
+ "tool_call_id",
410
+ "tool_result_ids",
411
+ "tool_use_ids",
412
+ "user_text",
413
+ "wake_state_from_text",
414
+ ]
@@ -0,0 +1,96 @@
1
+ """Claude and Codex transcript discovery repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class ChannelTranscriptRepository:
13
+ home: Path
14
+ cache: dict[str, Any]
15
+ scope: dict[str, Any]
16
+ now: Callable[[], float]
17
+
18
+ def set_scope(
19
+ self,
20
+ runtime: str,
21
+ *,
22
+ started_at: float | None = None,
23
+ codex_home: Path | None = None,
24
+ ) -> None:
25
+ self.scope["runtime"] = str(runtime or "").strip().casefold()
26
+ self.scope["started_at"] = (
27
+ self.now() if started_at is None else float(started_at)
28
+ )
29
+ self.scope["codex_home"] = (
30
+ Path(codex_home).expanduser()
31
+ if codex_home is not None
32
+ else None
33
+ )
34
+ self.cache.clear()
35
+ self.cache.update({"checked_at": 0.0, "path": None})
36
+
37
+ def roots(self) -> tuple[tuple[Path, str], ...]:
38
+ runtime = str(self.scope.get("runtime") or "").strip().casefold()
39
+ claude_root = (self.home / ".claude" / "projects", "*/*.jsonl")
40
+ configured_codex_home = self.scope.get("codex_home")
41
+ codex_home = (
42
+ Path(configured_codex_home)
43
+ if isinstance(configured_codex_home, Path)
44
+ else self.home / ".codex"
45
+ )
46
+ codex_root = (codex_home / "sessions", "**/*.jsonl")
47
+ if runtime == "codex":
48
+ return (codex_root,)
49
+ if runtime == "claude":
50
+ return (claude_root,)
51
+ return claude_root, codex_root
52
+
53
+ def latest(self, ttl_seconds: float = 2.0) -> Path | None:
54
+ now = self.now()
55
+ cached_at = float(self.cache.get("checked_at") or 0.0)
56
+ cached_path = self.cache.get("path")
57
+ if now - cached_at < ttl_seconds:
58
+ return cached_path if isinstance(cached_path, Path) else None
59
+ latest: Path | None = None
60
+ latest_mtime = -1.0
61
+ scope_started_at = float(self.scope.get("started_at") or 0.0)
62
+ for root, pattern in self.roots():
63
+ try:
64
+ paths = root.glob(pattern)
65
+ except Exception:
66
+ continue
67
+ for path in paths:
68
+ try:
69
+ mtime = path.stat().st_mtime
70
+ except OSError:
71
+ continue
72
+ if scope_started_at > 0 and mtime < scope_started_at - 1.0:
73
+ continue
74
+ if mtime > latest_mtime:
75
+ latest = path
76
+ latest_mtime = mtime
77
+ self.cache["checked_at"] = now
78
+ self.cache["path"] = latest
79
+ return latest
80
+
81
+ @staticmethod
82
+ def read_tail_text(
83
+ path: Path,
84
+ max_bytes: int = 512 * 1024,
85
+ ) -> str:
86
+ try:
87
+ size = path.stat().st_size
88
+ with path.open("rb") as stream:
89
+ if size > max_bytes:
90
+ stream.seek(max(0, size - max_bytes))
91
+ return stream.read(max_bytes).decode(
92
+ "utf-8",
93
+ errors="replace",
94
+ )
95
+ except Exception:
96
+ return ""
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import AbstractContextManager
4
+ from dataclasses import dataclass
5
+ import json
6
+ from pathlib import Path
7
+ import re
8
+ from typing import Any, Callable
9
+
10
+
11
+ _PROMPT_IDS_RE = re.compile(r"\b(?:id|ids|pending_ids|message_ids)\s*=\s*([0-9][0-9,\s]*)")
12
+
13
+
14
+ def prompt_match_text(text: str) -> str:
15
+ return re.sub(r"\s+", " ", str(text or "")).strip()
16
+
17
+
18
+ def prompt_contains(candidate: str, prompt: str) -> bool:
19
+ needle = prompt_match_text(prompt)
20
+ if not needle:
21
+ return False
22
+ haystack = prompt_match_text(candidate)
23
+ return bool(haystack and needle in haystack)
24
+
25
+
26
+ def prompt_message_ids(text: str) -> set[int]:
27
+ ids: set[int] = set()
28
+ for match in _PROMPT_IDS_RE.finditer(str(text or "")):
29
+ for raw in re.split(r"\D+", match.group(1)):
30
+ if not raw:
31
+ continue
32
+ try:
33
+ message_id = int(raw)
34
+ except (TypeError, ValueError):
35
+ continue
36
+ if message_id > 0:
37
+ ids.add(message_id)
38
+ return ids
39
+
40
+
41
+ def prompt_references_message_id(
42
+ text: str,
43
+ message_id: int,
44
+ prompt_texts: list[str] | tuple[str, ...] = (),
45
+ ) -> bool:
46
+ if message_id in prompt_message_ids(text):
47
+ return True
48
+ return any(prompt_contains(str(text or ""), prompt) for prompt in prompt_texts)
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class ChannelWakeClaimRepository:
53
+ path: Path
54
+ file_lock: Callable[[], AbstractContextManager[Any]]
55
+ now: Callable[[], float]
56
+ ttl_seconds: Callable[[], float]
57
+ log: Callable[[str, str], None]
58
+
59
+ def _read_locked(self) -> dict[str, Any]:
60
+ current = self.now()
61
+ ttl = self.ttl_seconds()
62
+ try:
63
+ data = json.loads(self.path.read_text(encoding="utf-8"))
64
+ except FileNotFoundError:
65
+ return {}
66
+ except (OSError, UnicodeError, json.JSONDecodeError, TypeError) as exc:
67
+ self.log(
68
+ "WARN",
69
+ f"channel_stdin_wake_claims_read_failed error={type(exc).__name__}: {exc}",
70
+ )
71
+ return {}
72
+ claims = data.get("claims") if isinstance(data, dict) else {}
73
+ if not isinstance(claims, dict):
74
+ return {}
75
+ out: dict[str, Any] = {}
76
+ for key, value in claims.items():
77
+ if not isinstance(value, dict):
78
+ continue
79
+ try:
80
+ claimed_at = float(value.get("claimed_at") or 0)
81
+ except (TypeError, ValueError):
82
+ claimed_at = 0.0
83
+ if claimed_at > 0 and current - claimed_at > ttl:
84
+ continue
85
+ out[str(key)] = dict(value)
86
+ return out
87
+
88
+ def _write_locked(self, claims: dict[str, Any]) -> None:
89
+ self.path.parent.mkdir(parents=True, exist_ok=True)
90
+ temporary = self.path.with_suffix(".json.tmp")
91
+ temporary.write_text(
92
+ json.dumps({"claims": claims}, ensure_ascii=False, separators=(",", ":")) + "\n",
93
+ encoding="utf-8",
94
+ )
95
+ temporary.replace(self.path)
96
+
97
+ def prompt(self, message_id: int) -> str:
98
+ if message_id <= 0:
99
+ return ""
100
+ with self.file_lock():
101
+ claim = self._read_locked().get(str(message_id))
102
+ if not isinstance(claim, dict):
103
+ return ""
104
+ prompt = claim.get("prompt")
105
+ return str(prompt) if isinstance(prompt, str) else ""
106
+
107
+ def claim(self, message_id: int, prompt: str) -> bool:
108
+ if message_id <= 0 or not prompt_match_text(prompt):
109
+ return True
110
+ with self.file_lock():
111
+ claims = self._read_locked()
112
+ if isinstance(claims.get(str(message_id)), dict):
113
+ return False
114
+ claims[str(message_id)] = {"claimed_at": self.now(), "prompt": prompt}
115
+ self._write_locked(claims)
116
+ return True
117
+
118
+ def clear(self, message_id: int) -> None:
119
+ if message_id <= 0:
120
+ return
121
+ with self.file_lock():
122
+ claims = self._read_locked()
123
+ if str(message_id) not in claims:
124
+ return
125
+ claims.pop(str(message_id), None)
126
+ self._write_locked(claims)