@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,234 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import contextmanager
4
+ from datetime import datetime, timezone
5
+ import json
6
+ from dataclasses import dataclass
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any, Callable
10
+ import time
11
+
12
+ from ciel_runtime_support.channel_message_policy import string_list
13
+
14
+
15
+ @contextmanager
16
+ def exclusive_file_lock(path: Path):
17
+ """Hold a cross-platform advisory lock beside a repository artifact."""
18
+
19
+ path.parent.mkdir(parents=True, exist_ok=True)
20
+ lock_path = path.with_name(path.name + ".lock")
21
+ with lock_path.open("a+b") as lock_file:
22
+ if os.name == "nt":
23
+ import msvcrt
24
+
25
+ lock_file.seek(0, os.SEEK_END)
26
+ if lock_file.tell() == 0:
27
+ lock_file.write(b"\0")
28
+ lock_file.flush()
29
+ lock_file.seek(0)
30
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
31
+ try:
32
+ yield
33
+ finally:
34
+ lock_file.seek(0)
35
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
36
+ else:
37
+ import fcntl
38
+
39
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
40
+ try:
41
+ yield
42
+ finally:
43
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class ChannelMessageAppendPorts:
48
+ condition: Any
49
+ file_lock: Callable[..., Any]
50
+ duplicate: Callable[[dict[str, Any]], dict[str, Any] | None]
51
+ normalize_recipients: Callable[[Any], list[str]]
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class ChannelMessageRepository:
56
+ path: Path
57
+ log: Callable[[str, str], None]
58
+ max_bytes: int = 10 * 1024 * 1024
59
+
60
+ def append(self, payload: dict[str, Any], ports: ChannelMessageAppendPorts) -> dict[str, Any]:
61
+ with ports.condition:
62
+ with ports.file_lock():
63
+ self.path.parent.mkdir(parents=True, exist_ok=True)
64
+ previous_max_id = self.max_id()
65
+ if self.path.exists() and self.path.stat().st_size > self.max_bytes:
66
+ self.path.replace(self.path.with_suffix(".jsonl.1"))
67
+ next_id = previous_max_id + 1
68
+ message = {
69
+ "id": next_id,
70
+ "time": time.strftime("%Y-%m-%dT%H:%M:%S"),
71
+ "channel": str(payload.get("channel") or "default"),
72
+ "sender_id": str(payload.get("sender_id") or payload.get("sender") or "anonymous"),
73
+ "recipients": ports.normalize_recipients(
74
+ payload.get("recipients", payload.get("recipient_id"))
75
+ ),
76
+ "thread_id": str(payload.get("thread_id") or payload.get("parent_id") or next_id),
77
+ "parent_id": payload.get("parent_id"),
78
+ "message": str(payload.get("message") or payload.get("text") or ""),
79
+ "kind": str(payload.get("kind") or "message"),
80
+ "meta": payload.get("meta") if isinstance(payload.get("meta"), dict) else {},
81
+ }
82
+ if payload.get("visibility") is not None:
83
+ message["visibility"] = str(payload.get("visibility") or "user")
84
+ if payload.get("delivery") is not None:
85
+ message["delivery"] = ports.normalize_recipients(payload.get("delivery"))
86
+ duplicate = ports.duplicate(message)
87
+ if duplicate:
88
+ returned = dict(duplicate)
89
+ returned["_ciel_runtime_duplicate"] = True
90
+ self.log(
91
+ "INFO",
92
+ f"chat_message_skipped_duplicate existing_id={duplicate.get('id')} "
93
+ f"channel={message.get('channel')} kind={message.get('kind')}",
94
+ )
95
+ return returned
96
+ with self.path.open("a", encoding="utf-8") as stream:
97
+ stream.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n")
98
+ ports.condition.notify_all()
99
+ return message
100
+
101
+ @staticmethod
102
+ def timestamp_seconds(item: dict[str, Any]) -> float | None:
103
+ raw = item.get("time") or item.get("created_at") or item.get("updated_at")
104
+ if not raw:
105
+ return None
106
+ if isinstance(raw, (int, float)):
107
+ return float(raw)
108
+ text = str(raw).strip()
109
+ if not text:
110
+ return None
111
+ try:
112
+ parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
113
+ except (TypeError, ValueError):
114
+ return None
115
+ if parsed.tzinfo is None:
116
+ parsed = parsed.replace(tzinfo=timezone.utc)
117
+ return parsed.timestamp()
118
+
119
+ def _jsonl_items(self, operation: str) -> list[dict[str, Any]]:
120
+ if not self.path.exists():
121
+ return []
122
+ items: list[dict[str, Any]] = []
123
+ try:
124
+ with self.path.open("r", encoding="utf-8") as stream:
125
+ for line in stream:
126
+ try:
127
+ item = json.loads(line)
128
+ except (json.JSONDecodeError, TypeError):
129
+ continue
130
+ if isinstance(item, dict):
131
+ items.append(item)
132
+ except OSError as exc:
133
+ self.log(
134
+ "WARN",
135
+ f"chat {operation} failed error={type(exc).__name__}: {exc}",
136
+ )
137
+ return items
138
+
139
+ def max_id(self) -> int:
140
+ max_id = 0
141
+ for item in self._jsonl_items("max id scan"):
142
+ try:
143
+ max_id = max(max_id, int(item.get("id") or 0))
144
+ except (TypeError, ValueError):
145
+ continue
146
+ return max_id
147
+
148
+ def max_id_before_epoch(self, cutoff_epoch: float) -> int:
149
+ max_id = 0
150
+ for item in self._jsonl_items("cutoff scan"):
151
+ try:
152
+ item_id = int(item.get("id") or 0)
153
+ except (TypeError, ValueError):
154
+ continue
155
+ if item_id <= 0:
156
+ continue
157
+ item_epoch = self.timestamp_seconds(item)
158
+ if item_epoch is not None and item_epoch < cutoff_epoch:
159
+ max_id = max(max_id, item_id)
160
+ return max_id
161
+
162
+ @staticmethod
163
+ def _visible_to(message: dict[str, Any], recipient: str | None) -> bool:
164
+ if not recipient:
165
+ return True
166
+ recipients = string_list(message.get("recipients"))
167
+ if not recipients or "all" in [value.lower() for value in recipients] or "*" in recipients:
168
+ return True
169
+ return recipient in recipients or recipient == str(message.get("sender_id") or "")
170
+
171
+ @classmethod
172
+ def _matches(
173
+ cls,
174
+ message: dict[str, Any],
175
+ channel: str | None,
176
+ recipient: str | None,
177
+ ) -> bool:
178
+ if channel:
179
+ meta = message.get("meta") if isinstance(message.get("meta"), dict) else {}
180
+ aliases = {
181
+ str(message.get("channel") or ""),
182
+ str(meta.get("room_id") or ""),
183
+ str(meta.get("room") or ""),
184
+ str(meta.get("channel") or ""),
185
+ }
186
+ if channel not in aliases:
187
+ return False
188
+ return cls._visible_to(message, recipient)
189
+
190
+ def read(
191
+ self,
192
+ after_id: int = 0,
193
+ channel: str | None = None,
194
+ recipient: str | None = None,
195
+ limit: int = 100,
196
+ ) -> list[dict[str, Any]]:
197
+ messages: list[dict[str, Any]] = []
198
+ for item in self._jsonl_items("read"):
199
+ try:
200
+ if int(item.get("id") or 0) <= after_id:
201
+ continue
202
+ except (TypeError, ValueError):
203
+ continue
204
+ if self._matches(item, channel, recipient):
205
+ messages.append(item)
206
+ if len(messages) >= limit:
207
+ break
208
+ return messages
209
+
210
+ def read_before(
211
+ self,
212
+ before_id: int = 0,
213
+ channel: str | None = None,
214
+ recipient: str | None = None,
215
+ limit: int = 100,
216
+ ) -> list[dict[str, Any]]:
217
+ messages: list[dict[str, Any]] = []
218
+ for item in self._jsonl_items("read before"):
219
+ try:
220
+ item_id = int(item.get("id") or 0)
221
+ except (TypeError, ValueError):
222
+ continue
223
+ if before_id > 0 and item_id >= before_id:
224
+ continue
225
+ if self._matches(item, channel, recipient):
226
+ messages.append(item)
227
+ if len(messages) > limit:
228
+ messages = messages[-limit:]
229
+ return messages
230
+
231
+ def recent_rows(self, limit: int) -> list[dict[str, Any]]:
232
+ if limit <= 0:
233
+ return []
234
+ return self._jsonl_items("duplicate scan")[-limit:]
@@ -0,0 +1,217 @@
1
+ """Native channel notification protocol projection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import Any, Callable
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class ChannelNotificationConfig:
13
+ notification_method: str
14
+ control_kinds: frozenset[str] | set[str] | tuple[str, ...]
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class ChannelNotificationPorts:
19
+ json_safe: Callable[[Any], Any]
20
+ string_list: Callable[[Any], list[str]]
21
+ external_provenance: Callable[[dict[str, Any]], bool]
22
+ wake_noise_reason: Callable[[dict[str, Any]], str | None]
23
+ superseded_ids: Callable[[list[dict[str, Any]]], set[str]]
24
+ log: Callable[[str, str], None]
25
+
26
+
27
+ class ChannelNotificationProjection:
28
+ def __init__(
29
+ self, config: ChannelNotificationConfig, ports: ChannelNotificationPorts
30
+ ) -> None:
31
+ self.config = config
32
+ self.ports = ports
33
+
34
+ def meta_value(self, value: Any) -> str:
35
+ if value is None:
36
+ return ""
37
+ if isinstance(value, str):
38
+ return value
39
+ if isinstance(value, bool):
40
+ return "true" if value else "false"
41
+ if isinstance(value, (int, float)):
42
+ return str(value)
43
+ return json.dumps(
44
+ self.ports.json_safe(value),
45
+ ensure_ascii=False,
46
+ separators=(",", ":"),
47
+ default=str,
48
+ )
49
+
50
+ def meta(self, message: dict[str, Any]) -> dict[str, str]:
51
+ raw_meta = message.get("meta") if isinstance(message.get("meta"), dict) else {}
52
+ meta: dict[str, str] = {}
53
+ for key, value in raw_meta.items():
54
+ name = str(key or "").strip()
55
+ if not name:
56
+ continue
57
+ meta[name] = self.meta_value(value)
58
+ message_recipients = (
59
+ self.ports.string_list(message.get("recipients"))
60
+ if message.get("recipients") is not None
61
+ else None
62
+ )
63
+ base = {
64
+ "ciel_runtime_message_id": message.get("id"),
65
+ "channel": message.get("channel") or "default",
66
+ "sender_id": message.get("sender_id") or "channel",
67
+ "thread_id": message.get("thread_id"),
68
+ "parent_id": message.get("parent_id"),
69
+ "kind": message.get("kind"),
70
+ "recipients": message_recipients,
71
+ }
72
+ for key, value in base.items():
73
+ if value is not None:
74
+ meta[key] = self.meta_value(value)
75
+ if raw_meta:
76
+ meta["ciel_runtime_meta_json"] = json.dumps(
77
+ self.ports.json_safe(raw_meta),
78
+ ensure_ascii=False,
79
+ separators=(",", ":"),
80
+ default=str,
81
+ )
82
+ return meta
83
+
84
+ def param_value(self, value: Any) -> Any:
85
+ if value is None:
86
+ return None
87
+ if isinstance(value, (str, bool, int, float)):
88
+ return value
89
+ return self.ports.json_safe(value)
90
+
91
+ def notification(self, message: dict[str, Any]) -> dict[str, Any]:
92
+ text = re.sub(r"\s+", " ", str(message.get("message") or "")).strip()
93
+ channel = str(message.get("channel") or "default")
94
+ sender = str(message.get("sender_id") or "channel")
95
+ prefix = f"[{channel}] {sender}"
96
+ content = f"{prefix}: {text}" if text else prefix
97
+ raw_meta = message.get("meta") if isinstance(message.get("meta"), dict) else {}
98
+ params: dict[str, Any] = {
99
+ "content": content,
100
+ "message": text,
101
+ "text": text,
102
+ "channel": channel,
103
+ "source": channel,
104
+ "sender_id": sender,
105
+ "meta": self.meta(message),
106
+ }
107
+ for key in ("id", "thread_id", "parent_id", "kind", "time", "recipients"):
108
+ if message.get(key) is not None:
109
+ value = (
110
+ self.ports.string_list(message.get(key))
111
+ if key == "recipients"
112
+ else message.get(key)
113
+ )
114
+ params[key] = self.param_value(value)
115
+ for key in (
116
+ "room_id",
117
+ "room",
118
+ "recipient_id",
119
+ "recipient",
120
+ "conversation_id",
121
+ "dm_id",
122
+ ):
123
+ value = raw_meta.get(key)
124
+ if value is not None and key not in params:
125
+ params[key] = self.param_value(value)
126
+ if "room_id" not in params and channel:
127
+ params["room_id"] = channel
128
+ return {
129
+ "jsonrpc": "2.0",
130
+ "method": self.config.notification_method,
131
+ "params": params,
132
+ }
133
+
134
+ def capabilities(
135
+ self,
136
+ ) -> dict[str, Any]:
137
+ return {
138
+ "tools": {"listChanged": False},
139
+ "experimental": {
140
+ "claude/channel": {},
141
+ },
142
+ }
143
+
144
+ def skip_reason(self, message: dict[str, Any]) -> str | None:
145
+ visibility = str(message.get("visibility") or "user").strip().lower()
146
+ if visibility in {"hidden", "internal", "transport", "control", "system"}:
147
+ return f"visibility_{visibility}"
148
+ meta = message.get("meta") if isinstance(message.get("meta"), dict) else {}
149
+ meta_kind = (
150
+ str(
151
+ meta.get("kind")
152
+ or meta.get("type")
153
+ or meta.get("event_type")
154
+ or meta.get("eventType")
155
+ or meta.get("event")
156
+ or meta.get("status")
157
+ or ""
158
+ )
159
+ .strip()
160
+ .lower()
161
+ )
162
+ if meta_kind in self.config.control_kinds:
163
+ return meta_kind
164
+ recipients = {
165
+ item.strip().lower()
166
+ for item in self.ports.string_list(message.get("recipients"))
167
+ }
168
+ if "internal" in recipients:
169
+ return "recipient_internal"
170
+ delivery = self.ports.string_list(message.get("delivery"))
171
+ if delivery:
172
+ normalized_delivery = {item.strip().lower() for item in delivery}
173
+ if not ({"all", "*", "native", "mcp"} & normalized_delivery):
174
+ return "delivery_not_native"
175
+ wake_reason = self.ports.wake_noise_reason(message)
176
+ if wake_reason:
177
+ return wake_reason
178
+ if not delivery and not self.ports.external_provenance(message):
179
+ return "unscoped_channel_message"
180
+ return None
181
+
182
+ def notifications_for_messages(
183
+ self,
184
+ messages: list[dict[str, Any]],
185
+ session: str = "",
186
+ ) -> tuple[int, list[tuple[int, dict[str, Any]]]]:
187
+ last_id = 0
188
+ events: list[tuple[int, dict[str, Any]]] = []
189
+ superseded_ids = self.ports.superseded_ids(messages)
190
+ for message in messages:
191
+ message_id = int(message.get("id") or 0)
192
+ last_id = max(last_id, message_id)
193
+ skip_reason = self.skip_reason(message)
194
+ if skip_reason:
195
+ self.ports.log(
196
+ "INFO",
197
+ f"channel_mcp_skipped_noise session={session or '-'} message_id={message.get('id')} channel={message.get('channel')} reason={skip_reason}",
198
+ )
199
+ continue
200
+ if message_id in superseded_ids:
201
+ self.ports.log(
202
+ "INFO",
203
+ f"channel_mcp_skipped_noise session={session or '-'} message_id={message.get('id')} channel={message.get('channel')} reason=superseded_channel_notice",
204
+ )
205
+ continue
206
+ notification = self.notification(message)
207
+ events.append((last_id, notification))
208
+ params = (
209
+ notification.get("params") if isinstance(notification, dict) else {}
210
+ )
211
+ room_id = params.get("room_id") if isinstance(params, dict) else None
212
+ recipients = params.get("recipients") if isinstance(params, dict) else None
213
+ self.ports.log(
214
+ "INFO",
215
+ f"channel_mcp_notification_prepared session={session or '-'} message_id={message.get('id')} channel={message.get('channel')} room_id={room_id or '-'} recipients={self.meta_value(recipients)[:120] if recipients is not None else '-'}",
216
+ )
217
+ return last_id, events
@@ -0,0 +1,162 @@
1
+ """Prelaunch channel panel projection policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import time
7
+ from typing import Any, Callable
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ChannelPanelPolicy:
12
+ builtin_router_probe_record: Callable[..., Any]
13
+ channel_specs: Callable[..., Any]
14
+ delivery_mode: Callable[..., Any]
15
+ official_plugins: dict[str, str]
16
+ probe_record_bucket: Callable[..., Any]
17
+ read_probe_cache: Callable[..., Any]
18
+
19
+
20
+ def channel_panel_rows(
21
+ cfg: dict[str, Any], *, policy: ChannelPanelPolicy
22
+ ) -> tuple[list[str], list[str]]:
23
+ OFFICIAL_CHANNEL_PLUGINS = policy.official_plugins
24
+ _builtin_router_probe_record = policy.builtin_router_probe_record
25
+ channel_probe_record_bucket = policy.probe_record_bucket
26
+ channel_specs = policy.channel_specs
27
+ read_channel_probe_cache = policy.read_probe_cache
28
+ channels = set(channel_specs(cfg))
29
+ cache = read_channel_probe_cache()
30
+ records = cache.get("servers") or []
31
+ if not any(isinstance(r, dict) and r.get("name") == "ciel-runtime-router" for r in records):
32
+ records = [_builtin_router_probe_record(), *records]
33
+ probed_at = cache.get("probed_at") or 0
34
+ capable_records = [r for r in records if channel_probe_record_bucket(r) == "capable"]
35
+ non_capable_records = [r for r in records if channel_probe_record_bucket(r) == "non_capable"]
36
+ inconclusive_records = [r for r in records if channel_probe_record_bucket(r) == "inconclusive"]
37
+ skipped_records = [r for r in records if channel_probe_record_bucket(r) == "skipped"]
38
+
39
+ rows: list[str] = []
40
+ values: list[str] = []
41
+
42
+ rows.append("[Auto-detected channel-capable]")
43
+ values.append("__heading__")
44
+ if capable_records:
45
+ for r in capable_records:
46
+ name = str(r.get("name") or "")
47
+ spec = f"server:{name}"
48
+ mark = "*" if spec in channels else " "
49
+ transport = str(r.get("transport") or "?")
50
+ source = str(r.get("source_path") or "")
51
+ if source == "<built-in>":
52
+ rows.append(f"{mark} {name:<14} ({transport}, built-in)")
53
+ else:
54
+ rows.append(f"{mark} {name:<14} ({transport})")
55
+ values.append(spec)
56
+ else:
57
+ hint = "press Re-probe now" if not probed_at else "none capable"
58
+ rows.append(f" ({hint})")
59
+ values.append("__noop__")
60
+
61
+ if non_capable_records:
62
+ rows.append("[Detected but not channel-capable]")
63
+ values.append("__heading__")
64
+ for r in non_capable_records:
65
+ name = str(r.get("name") or "")
66
+ transport = str(r.get("transport") or "?")
67
+ reason = str(r.get("reason") or "-")
68
+ rows.append(f" {name:<14} ({transport}) {reason}")
69
+ values.append("__noop__")
70
+
71
+ if inconclusive_records:
72
+ rows.append("[Probe inconclusive / selectable anyway]")
73
+ values.append("__heading__")
74
+ for r in inconclusive_records:
75
+ name = str(r.get("name") or "")
76
+ spec = f"server:{name}"
77
+ mark = "*" if spec in channels else " "
78
+ transport = str(r.get("transport") or "?")
79
+ reason = str(r.get("reason") or "-")
80
+ rows.append(f"{mark} {name:<14} ({transport}) {reason} [select anyway]")
81
+ values.append(spec)
82
+
83
+ if skipped_records:
84
+ rows.append("[Not probed]")
85
+ values.append("__heading__")
86
+ for r in skipped_records:
87
+ name = str(r.get("name") or "")
88
+ transport = str(r.get("transport") or "?")
89
+ reason = str(r.get("reason") or "-")
90
+ rows.append(f" {name:<14} ({transport}) {reason}")
91
+ values.append("__noop__")
92
+
93
+ rows.append("[Official plugins]")
94
+ values.append("__heading__")
95
+ for plugin_name, spec in OFFICIAL_CHANNEL_PLUGINS.items():
96
+ mark = "*" if spec in channels else " "
97
+ rows.append(f"{mark} {plugin_name:<10} {spec}")
98
+ values.append(spec)
99
+
100
+ covered: set[str] = set(OFFICIAL_CHANNEL_PLUGINS.values())
101
+ for r in records:
102
+ covered.add(f"server:{r.get('name')}")
103
+ custom_specs = [spec for spec in channel_specs(cfg) if spec not in covered]
104
+ if custom_specs:
105
+ rows.append("[Configured custom / imported]")
106
+ values.append("__heading__")
107
+ for spec in custom_specs:
108
+ rows.append(f"* {spec}")
109
+ values.append(spec)
110
+
111
+ rows.append("[Actions]")
112
+ values.append("__heading__")
113
+ if probed_at:
114
+ ts_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(probed_at))
115
+ rows.append(f"Re-probe now (last: {ts_str})")
116
+ else:
117
+ rows.append("Re-probe now (no cache yet)")
118
+ values.append("__reprobe__")
119
+ rows.append("+ Add custom channel...")
120
+ values.append("__add_custom__")
121
+ if channels:
122
+ rows.append("- Remove channel...")
123
+ values.append("__remove__")
124
+ rows.append("Clear all channels")
125
+ values.append("__clear__")
126
+ rows.append("Back")
127
+ values.append("back")
128
+ return rows, values
129
+
130
+
131
+ def _channel_panel_first_selectable(values: list[str]) -> int:
132
+ for idx, value in enumerate(values):
133
+ if value not in ("__heading__", "__noop__"):
134
+ return idx
135
+ return 0
136
+
137
+
138
+ def _channel_panel_step(values: list[str], start: int, delta: int) -> int:
139
+ if not values:
140
+ return 0
141
+ n = len(values)
142
+ idx = start
143
+ for _ in range(n):
144
+ idx = (idx + delta) % n
145
+ if values[idx] not in ("__heading__", "__noop__"):
146
+ return idx
147
+ return start
148
+
149
+
150
+ def channel_delivery_panel_rows(
151
+ cfg: dict[str, Any], *, policy: ChannelPanelPolicy
152
+ ) -> tuple[list[str], list[str]]:
153
+ channel_delivery_mode = policy.delivery_mode
154
+ current = channel_delivery_mode(cfg)
155
+ rows = [
156
+ f"{'*' if current == 'llm' else ' '} llm inject channel messages into next model request",
157
+ f"{'*' if current == 'native' else ' '} native Claude Code claude/channel bridge; requires Channels",
158
+ f"{'*' if current == 'stdin' else ' '} stdin PTY wake proxy; terminal input fallback",
159
+ "Back",
160
+ ]
161
+ return rows, ["llm", "native", "stdin", "back"]
162
+