@oneciel-ai/ciel-runtime 0.2.2 → 0.2.4

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 (136) hide show
  1. package/ciel_runtime.py +2555 -9635
  2. package/ciel_runtime_support/advisor_request_builder.py +8 -21
  3. package/ciel_runtime_support/anthropic_tool_turns.py +13 -8
  4. package/ciel_runtime_support/architecture.py +68 -0
  5. package/ciel_runtime_support/architecture_budget.py +1 -1
  6. package/ciel_runtime_support/channel_connection_context.py +233 -0
  7. package/ciel_runtime_support/channel_delivery_context.py +332 -0
  8. package/ciel_runtime_support/channel_mcp_context.py +313 -0
  9. package/ciel_runtime_support/channel_mcp_discovery.py +47 -0
  10. package/ciel_runtime_support/channel_mcp_transport.py +5 -1
  11. package/ciel_runtime_support/channel_message_context.py +212 -0
  12. package/ciel_runtime_support/channel_message_repository.py +14 -3
  13. package/ciel_runtime_support/channel_pending_injection.py +9 -0
  14. package/ciel_runtime_support/channel_probe_launch_context.py +213 -0
  15. package/ciel_runtime_support/channel_replay_policy.py +38 -0
  16. package/ciel_runtime_support/channel_runtime_environment.py +8 -0
  17. package/ciel_runtime_support/channel_session_context.py +236 -0
  18. package/ciel_runtime_support/channel_terminal_context.py +350 -0
  19. package/ciel_runtime_support/channel_wake_context.py +532 -0
  20. package/ciel_runtime_support/claude_environment.py +60 -0
  21. package/ciel_runtime_support/claude_launch_assembly.py +249 -0
  22. package/ciel_runtime_support/claude_router.py +62 -12
  23. package/ciel_runtime_support/cli_application_context.py +132 -0
  24. package/ciel_runtime_support/cli_assembly.py +50 -0
  25. package/ciel_runtime_support/codex_backend_context.py +363 -0
  26. package/ciel_runtime_support/codex_config.py +13 -1
  27. package/ciel_runtime_support/codex_launch_assembly.py +213 -0
  28. package/ciel_runtime_support/codex_launch_configuration.py +30 -1
  29. package/ciel_runtime_support/codex_mcp_integration.py +90 -8
  30. package/ciel_runtime_support/codex_model_catalog.py +4 -1
  31. package/ciel_runtime_support/codex_reasoning_rejects.py +225 -0
  32. package/ciel_runtime_support/codex_router.py +38 -8
  33. package/ciel_runtime_support/codex_turn_recovery.py +154 -0
  34. package/ciel_runtime_support/config_migrations.py +103 -0
  35. package/ciel_runtime_support/config_repository.py +30 -0
  36. package/ciel_runtime_support/configuration_cli.py +38 -0
  37. package/ciel_runtime_support/context_compaction.py +9 -4
  38. package/ciel_runtime_support/credential_management.py +12 -0
  39. package/ciel_runtime_support/credentials.py +12 -0
  40. package/ciel_runtime_support/github_copilot_oauth.py +2 -2
  41. package/ciel_runtime_support/hosted_formula_tools.py +216 -0
  42. package/ciel_runtime_support/kimi_runtime_context.py +208 -0
  43. package/ciel_runtime_support/llm_preset_context.py +338 -0
  44. package/ciel_runtime_support/managed_mcp_config.py +8 -4
  45. package/ciel_runtime_support/mcp_configuration_context.py +291 -0
  46. package/ciel_runtime_support/mcp_http_proxy.py +14 -8
  47. package/ciel_runtime_support/mcp_probe_transport.py +47 -15
  48. package/ciel_runtime_support/mcp_transport.py +14 -1
  49. package/ciel_runtime_support/native_context_recovery.py +72 -0
  50. package/ciel_runtime_support/ollama_catalog_context.py +213 -0
  51. package/ciel_runtime_support/ollama_stream_collection.py +103 -0
  52. package/ciel_runtime_support/ollama_thinking.py +6 -1
  53. package/ciel_runtime_support/ollama_wire_projection.py +157 -0
  54. package/ciel_runtime_support/openai_forwarding.py +32 -10
  55. package/ciel_runtime_support/openai_responses_router.py +12 -0
  56. package/ciel_runtime_support/package_lifecycle.py +39 -0
  57. package/ciel_runtime_support/prelaunch_assembly.py +37 -0
  58. package/ciel_runtime_support/prelaunch_panel_context.py +418 -0
  59. package/ciel_runtime_support/prelaunch_shell_context.py +394 -0
  60. package/ciel_runtime_support/prompt_compaction.py +144 -0
  61. package/ciel_runtime_support/prompt_injection.py +45 -0
  62. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +1 -1
  63. package/ciel_runtime_support/protocols/chat_projection.py +85 -5
  64. package/ciel_runtime_support/protocols/conversation_turn_policy.py +43 -0
  65. package/ciel_runtime_support/protocols/ollama_chat.py +31 -0
  66. package/ciel_runtime_support/protocols/ollama_response.py +57 -5
  67. package/ciel_runtime_support/protocols/openai_reasoning.py +5 -2
  68. package/ciel_runtime_support/protocols/openai_responses.py +61 -15
  69. package/ciel_runtime_support/provider_adapters.py +26 -0
  70. package/ciel_runtime_support/provider_administration_context.py +207 -0
  71. package/ciel_runtime_support/provider_config_mutations.py +3 -0
  72. package/ciel_runtime_support/provider_model_catalog_context.py +137 -0
  73. package/ciel_runtime_support/provider_model_context.py +107 -0
  74. package/ciel_runtime_support/provider_model_metadata_context.py +197 -0
  75. package/ciel_runtime_support/provider_model_selection.py +10 -3
  76. package/ciel_runtime_support/provider_models.py +45 -2
  77. package/ciel_runtime_support/provider_option_cli.py +19 -0
  78. package/ciel_runtime_support/provider_policy.py +1 -1
  79. package/ciel_runtime_support/provider_readiness_context.py +189 -0
  80. package/ciel_runtime_support/provider_request_builder.py +64 -28
  81. package/ciel_runtime_support/provider_responses_passthrough.py +21 -2
  82. package/ciel_runtime_support/provider_timeout_policy.py +54 -0
  83. package/ciel_runtime_support/provider_tool_policy.py +9 -1
  84. package/ciel_runtime_support/providers/__init__.py +6 -0
  85. package/ciel_runtime_support/providers/alibaba.py +634 -0
  86. package/ciel_runtime_support/providers/catalog.py +24 -16
  87. package/ciel_runtime_support/providers/deepseek.py +73 -0
  88. package/ciel_runtime_support/providers/github_copilot_oauth.py +22 -1
  89. package/ciel_runtime_support/providers/kimi.py +69 -9
  90. package/ciel_runtime_support/providers/ollama.py +8 -0
  91. package/ciel_runtime_support/providers/ollama_context.py +21 -2
  92. package/ciel_runtime_support/providers/vllm.py +7 -1
  93. package/ciel_runtime_support/response_collection.py +68 -18
  94. package/ciel_runtime_support/response_collection_context.py +391 -0
  95. package/ciel_runtime_support/response_stream_context.py +555 -0
  96. package/ciel_runtime_support/responses_input_compatibility.py +121 -0
  97. package/ciel_runtime_support/responses_usage_observer.py +83 -0
  98. package/ciel_runtime_support/router_client_lifecycle.py +1 -0
  99. package/ciel_runtime_support/router_http.py +245 -3
  100. package/ciel_runtime_support/router_observability_context.py +251 -0
  101. package/ciel_runtime_support/router_process_context.py +200 -0
  102. package/ciel_runtime_support/router_process_lifecycle.py +2 -0
  103. package/ciel_runtime_support/router_request_assembly.py +399 -0
  104. package/ciel_runtime_support/router_request_context.py +215 -0
  105. package/ciel_runtime_support/router_server_context.py +84 -0
  106. package/ciel_runtime_support/runaway_output_guard.py +488 -0
  107. package/ciel_runtime_support/runtime_asset_assembly.py +147 -0
  108. package/ciel_runtime_support/runtime_asset_context.py +297 -0
  109. package/ciel_runtime_support/runtime_constants.py +16 -1
  110. package/ciel_runtime_support/runtime_launch.py +9 -5
  111. package/ciel_runtime_support/runtime_launch_context.py +130 -0
  112. package/ciel_runtime_support/runtime_maintenance_assembly.py +60 -0
  113. package/ciel_runtime_support/runtime_maintenance_context.py +309 -0
  114. package/ciel_runtime_support/runtime_maintenance_services.py +265 -0
  115. package/ciel_runtime_support/runtime_paths.py +60 -40
  116. package/ciel_runtime_support/runtime_primitives.py +78 -0
  117. package/ciel_runtime_support/speech_http_controller.py +335 -0
  118. package/ciel_runtime_support/sse_stream_collection.py +236 -0
  119. package/ciel_runtime_support/statusline_script.py +57 -8
  120. package/ciel_runtime_support/streaming_anthropic.py +361 -24
  121. package/ciel_runtime_support/tool_schema.py +40 -2
  122. package/ciel_runtime_support/tool_side_effect_dedupe.py +117 -12
  123. package/ciel_runtime_support/upstream_dump.py +68 -0
  124. package/ciel_runtime_support/upstream_retry_context.py +259 -0
  125. package/ciel_runtime_support/web_ui.py +248 -1
  126. package/ciel_runtime_support/workspace_router_selection.py +86 -0
  127. package/docs/COLAB_SPEECH.md +32 -0
  128. package/docs/Configuration.md +50 -0
  129. package/docs/Test-Suite.md +1 -0
  130. package/package.json +4 -1
  131. package/scripts/colab/__pycache__/bootstrap_moss_tts.cpython-311.pyc +0 -0
  132. package/scripts/colab/__pycache__/bootstrap_qwen_asr.cpython-311.pyc +0 -0
  133. package/scripts/colab/bootstrap_moss_tts.py +106 -0
  134. package/scripts/colab/bootstrap_qwen_asr.py +106 -0
  135. package/scripts/configure_speech_workers.py +37 -0
  136. package/scripts/deploy_colab_speech.ps1 +47 -0
@@ -7,6 +7,8 @@ import urllib.error
7
7
  from dataclasses import dataclass
8
8
  from typing import Any, Callable
9
9
 
10
+ from .mcp_transport import negotiated_protocol_version
11
+
10
12
 
11
13
  @dataclass(frozen=True, slots=True)
12
14
  class ChannelMcpTransportConfig:
@@ -262,9 +264,10 @@ class ChannelMcpTransport:
262
264
  },
263
265
  },
264
266
  }
265
- _result, session_id = self.http.streamable_post(
267
+ result, session_id = self.http.streamable_post(
266
268
  endpoint, headers, initialize, timeout, protocol_version
267
269
  )
270
+ protocol_version = negotiated_protocol_version(result, protocol_version)
268
271
  if requires_session and not session_id:
269
272
  self.effects.set_state(
270
273
  name,
@@ -291,6 +294,7 @@ class ChannelMcpTransport:
291
294
  mcp_endpoint=endpoint,
292
295
  mcp_initialized=True,
293
296
  mcp_session_id=session_id,
297
+ mcp_protocol_version=protocol_version,
294
298
  mcp_last_error=None,
295
299
  mcp_rpc_results={},
296
300
  )
@@ -0,0 +1,212 @@
1
+ """Durable channel message queue and launch-dedupe bounded context."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Callable
8
+
9
+ from .channel_launch_guard_repository import ChannelLaunchGuardRepository
10
+ from .channel_message_dedupe import (
11
+ ChannelMessageDedupePorts,
12
+ ChannelMessageDedupeService,
13
+ )
14
+ from .channel_message_repository import (
15
+ ChannelMessageAppendPorts,
16
+ ChannelMessageRepository,
17
+ )
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ChannelMessageStoragePorts:
22
+ path: Callable[[], Path]
23
+ max_bytes: int
24
+ condition: Any
25
+ file_lock: Callable[[Path], Any]
26
+ log: Callable[[str, str], None]
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ChannelMessageIdentityPorts:
31
+ stable_key: Callable[[dict[str, Any]], Any]
32
+ fallback_key: Callable[[dict[str, Any]], Any]
33
+ timestamp_seconds: Callable[[Any], float]
34
+ normalize_recipients: Callable[[Any], list[str]]
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class ChannelMessageLaunchPorts:
39
+ guard_path: Callable[[], Path]
40
+ now: Callable[[], float]
41
+ recent_scan_limit: int
42
+ fallback_ttl_seconds: float
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class ChannelMessageCachePorts:
47
+ read_next_id: Callable[[], int | None]
48
+ write_next_id: Callable[[int], None]
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class ChannelMessageContext:
53
+ storage: ChannelMessageStoragePorts
54
+ identity: ChannelMessageIdentityPorts
55
+ launch: ChannelMessageLaunchPorts
56
+ cache: ChannelMessageCachePorts
57
+
58
+ def repository(self) -> ChannelMessageRepository:
59
+ return ChannelMessageRepository(
60
+ path=self.storage.path(),
61
+ log=self.storage.log,
62
+ max_bytes=self.storage.max_bytes,
63
+ )
64
+
65
+ def max_id(self) -> int:
66
+ return self.repository().max_id()
67
+
68
+ def max_id_before_epoch(self, cutoff_epoch: float) -> int:
69
+ return self.repository().max_id_before_epoch(cutoff_epoch)
70
+
71
+ def file_lock(self) -> Any:
72
+ return self.storage.file_lock(self.storage.path())
73
+
74
+ def read(
75
+ self,
76
+ after_id: int = 0,
77
+ channel: str | None = None,
78
+ recipient: str | None = None,
79
+ limit: int = 100,
80
+ ) -> list[dict[str, Any]]:
81
+ return self.repository().read(after_id, channel, recipient, limit)
82
+
83
+ def read_before(
84
+ self,
85
+ before_id: int = 0,
86
+ channel: str | None = None,
87
+ recipient: str | None = None,
88
+ limit: int = 100,
89
+ ) -> list[dict[str, Any]]:
90
+ return self.repository().read_before(before_id, channel, recipient, limit)
91
+
92
+ def recent_rows(self, limit: int | None = None) -> list[dict[str, Any]]:
93
+ return self.repository().recent_rows(
94
+ self.launch.recent_scan_limit if limit is None else limit
95
+ )
96
+
97
+ def launch_guard_repository(self) -> ChannelLaunchGuardRepository:
98
+ return ChannelLaunchGuardRepository(
99
+ path=self.launch.guard_path(),
100
+ now=self.launch.now,
101
+ log=self.storage.log,
102
+ )
103
+
104
+ def launch_guard(self) -> dict[str, Any] | None:
105
+ return self.launch_guard_repository().read()
106
+
107
+ def write_launch_guard(
108
+ self, max_existing_id: int, ttl_seconds: float = 180.0
109
+ ) -> None:
110
+ self.launch_guard_repository().write(max_existing_id, ttl_seconds)
111
+
112
+ def duplicate(self, message: dict[str, Any]) -> dict[str, Any] | None:
113
+ return ChannelMessageDedupeService(
114
+ ports=ChannelMessageDedupePorts(
115
+ stable_key=self.identity.stable_key,
116
+ fallback_key=self.identity.fallback_key,
117
+ recent_rows=self.recent_rows,
118
+ launch_guard=self.launch_guard,
119
+ timestamp_seconds=self.identity.timestamp_seconds,
120
+ now=self.launch.now,
121
+ ),
122
+ fallback_ttl_seconds=self.launch.fallback_ttl_seconds,
123
+ ).duplicate(message)
124
+
125
+ def initialize_next_id(self) -> int:
126
+ cached = self.cache.read_next_id()
127
+ if cached is not None:
128
+ return cached
129
+ next_id = self.max_id() + 1
130
+ self.cache.write_next_id(next_id)
131
+ return next_id
132
+
133
+ def append(self, payload: dict[str, Any]) -> dict[str, Any]:
134
+ message = self.repository().append(
135
+ payload,
136
+ ChannelMessageAppendPorts(
137
+ self.storage.condition,
138
+ self.file_lock,
139
+ self.duplicate,
140
+ self.identity.normalize_recipients,
141
+ ),
142
+ )
143
+ self.cache.write_next_id(int(message.get("id") or 0) + 1)
144
+ return message
145
+
146
+
147
+ @dataclass(frozen=True, slots=True)
148
+ class ChannelMessageCompatibilityApi:
149
+ context: Callable[[], ChannelMessageContext]
150
+
151
+ def repository(self) -> ChannelMessageRepository:
152
+ return self.context().repository()
153
+
154
+ def max_id(self) -> int:
155
+ return self.context().max_id()
156
+
157
+ def max_id_before_epoch(self, cutoff_epoch: float) -> int:
158
+ return self.context().max_id_before_epoch(cutoff_epoch)
159
+
160
+ def file_lock(self) -> Any:
161
+ return self.context().file_lock()
162
+
163
+ def read(
164
+ self,
165
+ after_id: int = 0,
166
+ channel: str | None = None,
167
+ recipient: str | None = None,
168
+ limit: int = 100,
169
+ ) -> list[dict[str, Any]]:
170
+ return self.context().read(after_id, channel, recipient, limit)
171
+
172
+ def read_before(
173
+ self,
174
+ before_id: int = 0,
175
+ channel: str | None = None,
176
+ recipient: str | None = None,
177
+ limit: int = 100,
178
+ ) -> list[dict[str, Any]]:
179
+ return self.context().read_before(before_id, channel, recipient, limit)
180
+
181
+ def recent_rows(self, limit: int | None = None) -> list[dict[str, Any]]:
182
+ return self.context().recent_rows(limit)
183
+
184
+ def launch_guard_repository(self) -> ChannelLaunchGuardRepository:
185
+ return self.context().launch_guard_repository()
186
+
187
+ def launch_guard(self) -> dict[str, Any] | None:
188
+ return self.context().launch_guard()
189
+
190
+ def write_launch_guard(
191
+ self, max_existing_id: int, ttl_seconds: float = 180.0
192
+ ) -> None:
193
+ self.context().write_launch_guard(max_existing_id, ttl_seconds)
194
+
195
+ def duplicate(self, message: dict[str, Any]) -> dict[str, Any] | None:
196
+ return self.context().duplicate(message)
197
+
198
+ def initialize_next_id(self) -> int:
199
+ return self.context().initialize_next_id()
200
+
201
+ def append(self, payload: dict[str, Any]) -> dict[str, Any]:
202
+ return self.context().append(payload)
203
+
204
+
205
+ __all__ = [
206
+ "ChannelMessageCachePorts",
207
+ "ChannelMessageCompatibilityApi",
208
+ "ChannelMessageContext",
209
+ "ChannelMessageIdentityPorts",
210
+ "ChannelMessageLaunchPorts",
211
+ "ChannelMessageStoragePorts",
212
+ ]
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from contextlib import contextmanager
4
- from datetime import datetime, timezone
4
+ from datetime import datetime
5
5
  import json
6
6
  from dataclasses import dataclass
7
7
  import os
@@ -65,9 +65,11 @@ class ChannelMessageRepository:
65
65
  if self.path.exists() and self.path.stat().st_size > self.max_bytes:
66
66
  self.path.replace(self.path.with_suffix(".jsonl.1"))
67
67
  next_id = previous_max_id + 1
68
+ created_at = time.time()
68
69
  message = {
69
70
  "id": next_id,
70
- "time": time.strftime("%Y-%m-%dT%H:%M:%S"),
71
+ "time": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(created_at)),
72
+ "created_at_epoch": created_at,
71
73
  "channel": str(payload.get("channel") or "default"),
72
74
  "sender_id": str(payload.get("sender_id") or payload.get("sender") or "anonymous"),
73
75
  "recipients": ports.normalize_recipients(
@@ -100,6 +102,12 @@ class ChannelMessageRepository:
100
102
 
101
103
  @staticmethod
102
104
  def timestamp_seconds(item: dict[str, Any]) -> float | None:
105
+ raw_epoch = item.get("created_at_epoch")
106
+ if raw_epoch is not None:
107
+ try:
108
+ return float(raw_epoch)
109
+ except (TypeError, ValueError):
110
+ pass
103
111
  raw = item.get("time") or item.get("created_at") or item.get("updated_at")
104
112
  if not raw:
105
113
  return None
@@ -113,7 +121,10 @@ class ChannelMessageRepository:
113
121
  except (TypeError, ValueError):
114
122
  return None
115
123
  if parsed.tzinfo is None:
116
- parsed = parsed.replace(tzinfo=timezone.utc)
124
+ # Historical messages were written with time.strftime(), i.e. in
125
+ # the machine's local timezone. Treating those values as UTC can
126
+ # move the replay boundary by several hours.
127
+ return parsed.timestamp()
117
128
  return parsed.timestamp()
118
129
 
119
130
  def _jsonl_items(self, operation: str) -> list[dict[str, Any]]:
@@ -52,6 +52,7 @@ class ChannelInjectionIO:
52
52
  @dataclass(frozen=True, slots=True)
53
53
  class ChannelInjectionPolicy:
54
54
  wake_batch_limit: Callable[[], int]
55
+ replay_skip_reason: Callable[[dict[str, Any]], str]
55
56
 
56
57
 
57
58
  @dataclass(frozen=True, slots=True)
@@ -115,6 +116,14 @@ def inject_pending_channel_messages(
115
116
  if web_chat_only and not state.message_is_web_chat(message):
116
117
  io.log("INFO", f"channel_stdin_proxy_skipped_noise message_id={message_id} channel={channel} reason=not_web_chat")
117
118
  continue
119
+ replay_skip_reason = services.policy.replay_skip_reason(message)
120
+ if replay_skip_reason:
121
+ io.log(
122
+ "WARN",
123
+ f"channel_stdin_proxy_skipped_noise message_id={message_id} "
124
+ f"channel={channel} reason={replay_skip_reason}",
125
+ )
126
+ continue
118
127
  skip_reason = state.message_skip_reason(message)
119
128
  if skip_reason:
120
129
  io.log("INFO", f"channel_stdin_proxy_skipped_noise message_id={message_id} channel={channel} reason={skip_reason}")
@@ -0,0 +1,213 @@
1
+ """Channel probe-cache decisions and runtime launch orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Callable, Iterable
8
+
9
+ from .codex_channel_sse_launch import (
10
+ CodexChannelSseEffects,
11
+ CodexChannelSseLaunchService,
12
+ CodexChannelSseQueryPorts,
13
+ )
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class ChannelProbeLaunchDiscoveryPorts:
18
+ discover_servers: Callable[..., dict[str, dict[str, Any]]]
19
+ cached_external_capable: Callable[[], list[str]]
20
+ channel_specs: Callable[[dict[str, Any], list[str]], list[str]]
21
+ server_names: Callable[[Iterable[str]], list[str]]
22
+ codex_capable_names: Callable[..., list[str]]
23
+ external_names: Callable[..., list[str]]
24
+ dedupe: Callable[[Iterable[str]], list[str]]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class ChannelProbeLaunchCachePorts:
29
+ service: Callable[[], Any]
30
+ read: Callable[[], dict[str, Any]]
31
+ records: Callable[[], list[dict[str, Any]]]
32
+ refresh: Callable[..., dict[str, Any]]
33
+ bucket: Callable[[dict[str, Any]], str]
34
+ panel_rows: Callable[[dict[str, Any]], tuple[list[str], list[str]]]
35
+ log: Callable[[str, str], None]
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ChannelProbeLaunchEffects:
40
+ delivery_mode: Callable[[dict[str, Any]], str]
41
+ auto_start: Callable[..., list[dict[str, Any]]]
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class ChannelProbeLaunchContext:
46
+ discovery: ChannelProbeLaunchDiscoveryPorts
47
+ cache: ChannelProbeLaunchCachePorts
48
+ effects: ChannelProbeLaunchEffects
49
+ native_channel_names: frozenset[str]
50
+
51
+ def native_auto_capable_names(
52
+ self, passthrough: list[str] | None = None
53
+ ) -> list[str]:
54
+ discovered = set(
55
+ self.discovery.discover_servers(passthrough or []).keys()
56
+ )
57
+ if not discovered:
58
+ return []
59
+ return [
60
+ name
61
+ for name in self.discovery.cached_external_capable()
62
+ if name in discovered
63
+ ]
64
+
65
+ def start_codex_sse(
66
+ self,
67
+ cfg: dict[str, Any],
68
+ codex_mcp_config: Path | None,
69
+ allowed_server_names: Iterable[str] | None = None,
70
+ ) -> list[dict[str, Any]]:
71
+ service = CodexChannelSseLaunchService(
72
+ query=CodexChannelSseQueryPorts(
73
+ delivery_mode=self.effects.delivery_mode,
74
+ channel_specs=self.discovery.channel_specs,
75
+ server_names=self.discovery.server_names,
76
+ capable_names=self.discovery.codex_capable_names,
77
+ dedupe=self.discovery.dedupe,
78
+ ),
79
+ effects=CodexChannelSseEffects(
80
+ auto_start=self.effects.auto_start,
81
+ log=self.cache.log,
82
+ ),
83
+ native_channel_names=frozenset(
84
+ name.casefold() for name in self.native_channel_names
85
+ ),
86
+ )
87
+ return service.start(cfg, codex_mcp_config, allowed_server_names)
88
+
89
+ def summary(self, prefix: str, cache: dict[str, Any]) -> str:
90
+ records = [
91
+ record
92
+ for record in cache.get("servers") or []
93
+ if isinstance(record, dict)
94
+ ]
95
+ grouped = {
96
+ name: [
97
+ record for record in records if self.cache.bucket(record) == name
98
+ ]
99
+ for name in ("capable", "inconclusive", "non_capable")
100
+ }
101
+ return (
102
+ f"{prefix}: {len(grouped['capable'])} channel-capable, "
103
+ f"{len(grouped['inconclusive'])} inconclusive, "
104
+ f"{len(grouped['non_capable'])} non-capable server(s)."
105
+ )
106
+
107
+ def panel_rows(
108
+ self, cfg: dict[str, Any], passthrough: list[str]
109
+ ) -> tuple[list[str], list[str], list[str]]:
110
+ messages: list[str] = []
111
+ if self.needs_refresh(cfg, passthrough):
112
+ try:
113
+ self.cache.log(
114
+ "INFO",
115
+ "channel_probe_menu_refresh "
116
+ "reason=missing_cache_or_selected_server",
117
+ )
118
+ result = self.cache.refresh(passthrough)
119
+ messages = [self.summary("Probe complete", result)]
120
+ except Exception as exc:
121
+ self.cache.log(
122
+ "WARN",
123
+ "channel_probe_menu_refresh_failed "
124
+ f"error={type(exc).__name__}: {exc}",
125
+ )
126
+ messages = [
127
+ f"Channel probe failed: {type(exc).__name__}: {exc}"
128
+ ]
129
+ rows, values = self.cache.panel_rows(cfg)
130
+ return rows, values, messages
131
+
132
+ def candidate_names(
133
+ self,
134
+ cfg: dict[str, Any],
135
+ passthrough: list[str],
136
+ extra_config_paths: list[Path | str] | None = None,
137
+ ) -> list[str]:
138
+ return self.cache.service().candidate_names(
139
+ self.discovery.channel_specs(cfg, passthrough),
140
+ lambda: self.discovery.external_names(
141
+ passthrough,
142
+ extra_config_paths=extra_config_paths,
143
+ ),
144
+ )
145
+
146
+ def needs_refresh(
147
+ self,
148
+ cfg: dict[str, Any],
149
+ passthrough: list[str],
150
+ extra_config_paths: list[Path | str] | None = None,
151
+ ) -> bool:
152
+ candidates = self.candidate_names(
153
+ cfg, passthrough, extra_config_paths=extra_config_paths
154
+ )
155
+ return self.cache.service().needs_refresh(
156
+ self.cache.read(), self.cache.records(), candidates
157
+ )
158
+
159
+ def ensure_cache(
160
+ self,
161
+ cfg: dict[str, Any],
162
+ passthrough: list[str],
163
+ extra_config_paths: list[Path | str] | None = None,
164
+ ) -> bool:
165
+ needed = self.needs_refresh(
166
+ cfg, passthrough, extra_config_paths=extra_config_paths
167
+ )
168
+ return self.cache.service().ensure_refresh(
169
+ needed,
170
+ lambda: self.cache.refresh(
171
+ passthrough,
172
+ **(
173
+ {"extra_config_paths": extra_config_paths}
174
+ if extra_config_paths is not None
175
+ else {}
176
+ ),
177
+ ),
178
+ )
179
+
180
+
181
+ @dataclass(frozen=True, slots=True)
182
+ class ChannelProbeLaunchCompatibilityApi:
183
+ context: Callable[[], ChannelProbeLaunchContext]
184
+
185
+ def native_auto_capable_names(self, *args: Any, **kwargs: Any) -> list[str]:
186
+ return self.context().native_auto_capable_names(*args, **kwargs)
187
+
188
+ def start_codex_sse(self, *args: Any, **kwargs: Any) -> list[dict[str, Any]]:
189
+ return self.context().start_codex_sse(*args, **kwargs)
190
+
191
+ def summary(self, *args: Any, **kwargs: Any) -> str:
192
+ return self.context().summary(*args, **kwargs)
193
+
194
+ def panel_rows(self, *args: Any, **kwargs: Any) -> tuple[list[str], list[str], list[str]]:
195
+ return self.context().panel_rows(*args, **kwargs)
196
+
197
+ def candidate_names(self, *args: Any, **kwargs: Any) -> list[str]:
198
+ return self.context().candidate_names(*args, **kwargs)
199
+
200
+ def needs_refresh(self, *args: Any, **kwargs: Any) -> bool:
201
+ return self.context().needs_refresh(*args, **kwargs)
202
+
203
+ def ensure_cache(self, *args: Any, **kwargs: Any) -> bool:
204
+ return self.context().ensure_cache(*args, **kwargs)
205
+
206
+
207
+ __all__ = [
208
+ "ChannelProbeLaunchCachePorts",
209
+ "ChannelProbeLaunchCompatibilityApi",
210
+ "ChannelProbeLaunchContext",
211
+ "ChannelProbeLaunchDiscoveryPorts",
212
+ "ChannelProbeLaunchEffects",
213
+ ]
@@ -0,0 +1,38 @@
1
+ """Safety policy for replaying durable channel messages into an agent terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Callable
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class ChannelReplaySafetyPolicy:
11
+ now: Callable[[], float]
12
+ replay_ttl_seconds: Callable[[], float]
13
+ timestamp_seconds: Callable[[dict[str, Any]], float | None]
14
+ is_web_chat: Callable[[dict[str, Any]], bool]
15
+
16
+ def skip_reason(self, message: dict[str, Any]) -> str:
17
+ """Expire old browser commands independently of startup cursor state.
18
+
19
+ Browser messages are durable history, but they are also executable input.
20
+ A cursor or launch-hook failure must never turn old history back into a
21
+ command after a process or machine restart.
22
+ """
23
+
24
+ if not self.is_web_chat(message):
25
+ return ""
26
+ created_at = self.timestamp_seconds(message)
27
+ if created_at is None:
28
+ return ""
29
+ ttl = max(1.0, float(self.replay_ttl_seconds()))
30
+ age = self.now() - created_at
31
+ if age > ttl:
32
+ return "stale_web_chat_replay"
33
+ if age < -300.0:
34
+ return "future_web_chat_timestamp"
35
+ return ""
36
+
37
+
38
+ __all__ = ["ChannelReplaySafetyPolicy"]
@@ -55,6 +55,14 @@ class ChannelRuntimeEnvironmentPolicy:
55
55
  maximum=50,
56
56
  )
57
57
 
58
+ def web_chat_replay_ttl_seconds(self) -> float:
59
+ return self._bounded_float(
60
+ self.environment.get("CIEL_RUNTIME_WEB_CHAT_REPLAY_TTL_SECONDS"),
61
+ default=600.0,
62
+ minimum=30.0,
63
+ maximum=3600.0,
64
+ )
65
+
58
66
  def wake_claim_ttl_seconds(self) -> float:
59
67
  return self._bounded_float(
60
68
  self.environment.get(