@oneciel-ai/ciel-runtime 0.2.22 → 0.2.23

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 (86) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +150 -57
  3. package/ciel-runtime-stop.cmd +1 -0
  4. package/ciel-runtime.cmd +1 -0
  5. package/ciel-runtimectl.cmd +1 -0
  6. package/ciel_runtime.py +56 -53
  7. package/ciel_runtime_support/anthropic_tool_turns.py +81 -0
  8. package/ciel_runtime_support/architecture.py +17 -0
  9. package/ciel_runtime_support/channel_injection.py +113 -3
  10. package/ciel_runtime_support/channel_message_prompt.py +15 -0
  11. package/ciel_runtime_support/channel_pending_injection.py +28 -4
  12. package/ciel_runtime_support/channel_pending_poll.py +2 -0
  13. package/ciel_runtime_support/channel_terminal_context.py +6 -0
  14. package/ciel_runtime_support/channel_terminal_dispatch.py +2 -0
  15. package/ciel_runtime_support/channel_terminal_proxy.py +8 -2
  16. package/ciel_runtime_support/channel_wake_claim_repository.py +7 -0
  17. package/ciel_runtime_support/channel_wake_context.py +7 -3
  18. package/ciel_runtime_support/cli_parser.py +6 -0
  19. package/ciel_runtime_support/codex_backend_context.py +4 -0
  20. package/ciel_runtime_support/codex_turn_recovery.py +75 -12
  21. package/ciel_runtime_support/config_migrations.py +102 -10
  22. package/ciel_runtime_support/config_repository.py +9 -0
  23. package/ciel_runtime_support/event_settings_cli.py +284 -2
  24. package/ciel_runtime_support/openai_chat_passthrough.py +2 -0
  25. package/ciel_runtime_support/protocols/openai_responses.py +22 -18
  26. package/ciel_runtime_support/provider_adapters.py +7 -0
  27. package/ciel_runtime_support/provider_policy.py +10 -1
  28. package/ciel_runtime_support/provider_request_builder.py +9 -2
  29. package/ciel_runtime_support/provider_responses_passthrough.py +4 -0
  30. package/ciel_runtime_support/providers/__init__.py +2 -0
  31. package/ciel_runtime_support/providers/alibaba.py +33 -10
  32. package/ciel_runtime_support/providers/constants.py +1 -0
  33. package/ciel_runtime_support/providers/opencode.py +25 -5
  34. package/ciel_runtime_support/providers/opencode_go.py +3 -3
  35. package/ciel_runtime_support/providers/openrouter.py +76 -3
  36. package/ciel_runtime_support/providers/tabitoken.py +130 -0
  37. package/ciel_runtime_support/remote_instructions.py +20 -6
  38. package/ciel_runtime_support/remote_memory.py +515 -55
  39. package/ciel_runtime_support/responses_input_compatibility.py +14 -0
  40. package/ciel_runtime_support/router_http.py +5 -0
  41. package/ciel_runtime_support/runtime_constants.py +1 -1
  42. package/ciel_runtime_support/runtime_launch.py +9 -2
  43. package/ciel_runtime_support/runtime_paths.py +2 -0
  44. package/ciel_runtime_support/tool_side_effect_dedupe.py +7 -4
  45. package/ciel_runtime_support/transcript_delta_delivery.py +333 -0
  46. package/ciel_runtime_support/usage_events.py +45 -3
  47. package/ciel_runtime_support/usage_service.py +871 -0
  48. package/ciel_runtime_support/windows_conpty.py +108 -2
  49. package/ciel_runtime_support/workspace_mcp.py +4 -0
  50. package/docs/CLI-Reference.md +11 -0
  51. package/docs/Configuration.md +7 -2
  52. package/docs/MCP-Channels.md +7 -3
  53. package/docs/Module-Map.md +4 -0
  54. package/docs/Providers.md +32 -0
  55. package/docs/Remote-Memory.md +29 -10
  56. package/docs/Test-Suite.md +1 -0
  57. package/docs/journal/2026/08/20/kevin-wake-orphan-tool-repair.md +161 -0
  58. package/docs/journal/2026/08/20/nightly-push-local-deployment.md +59 -2
  59. package/docs/journal/2026/08/20/wing-remote-memory-replication-verification.md +107 -0
  60. package/docs/journal/2026/08/21/alibaba-singapore-qwen38-official-alignment.md +64 -0
  61. package/docs/journal/2026/08/21/codex-visible-sse-wake-submit-regression.md +85 -0
  62. package/docs/journal/2026/08/21/external-sse-wake-only-change-analysis.md +131 -0
  63. package/docs/journal/2026/08/21/hyundai-invalid-replayed-tool-name-repair.okf +88 -0
  64. package/docs/journal/2026/08/21/local-stale-temporary-runtime-pin-repair.okf +118 -0
  65. package/docs/journal/2026/08/21/transcript-compaction-external-events.okf +131 -0
  66. package/docs/journal/2026/08/21/wing-ai-net-mcp-boundary-diagnosis.okf +129 -0
  67. package/docs/journal/2026/08/21/wing-cold-start-wake-submit-repair.okf +161 -0
  68. package/docs/journal/2026/08/21/wing-powershell-memory-runaway.okf +101 -0
  69. package/docs/journal/2026/08/21/wing-repeated-reasoning-tool-turn-repair.okf +112 -0
  70. package/docs/journal/2026/08/21/wing-repeated-tool-guard-finalization.okf +81 -0
  71. package/docs/journal/2026/08/21/wing-working-directory-memory-deployment.md +110 -0
  72. package/docs/journal/2026/08/21/wing-workspace-state-memory-prompt-injection.md +88 -0
  73. package/docs/journal/2026/08/22/alibaba-singapore-dual-endpoint-wire-alignment.okf +142 -0
  74. package/docs/journal/2026/08/22/delluhiold-claude-compaction-empty-tool-name.okf +168 -0
  75. package/docs/journal/2026/08/22/remote-memory-relative-prompt-paths.okf +79 -0
  76. package/docs/journal/2026/08/22/remote-memory-root-system-prompt.okf +121 -0
  77. package/docs/journal/2026/08/23/claude-web-chat-stateless-mcp-repair.okf +97 -0
  78. package/docs/journal/2026/08/23/cross-runtime-token-usage-interfaces-research.okf +378 -0
  79. package/docs/journal/2026/08/23/main-0.2.23-readme-release.okf +81 -0
  80. package/docs/journal/2026/08/23/mia-openrouter-ox-alpha-latency-diagnosis.okf +146 -0
  81. package/docs/journal/2026/08/23/openrouter-opencode-ox-alpha-support.okf +114 -0
  82. package/docs/journal/2026/08/23/tabitoken-provider-model-catalog.okf +97 -0
  83. package/docs/journal/2026/08/23/usage-stream-snapshot-implementation.okf +134 -0
  84. package/docs/usage-observability.md +141 -0
  85. package/install.ps1 +24 -3
  86. package/package.json +2 -1
@@ -22,6 +22,10 @@ from .base import (
22
22
  from .constants import DEFAULT_REQUEST_TIMEOUT_MS, PROVIDER_DEFAULT_BASE_URLS
23
23
 
24
24
 
25
+ OPENCODE_ZEN_OX_ALPHA_FREE_MODEL = "x-preview-f-free"
26
+ OPENCODE_GO_OX_ALPHA_FREE_MODEL = "ox-alpha-free"
27
+
28
+
25
29
  @dataclass(frozen=True)
26
30
  class OpenCodeProviderAdapter(HttpBearerProviderAdapter):
27
31
  name: str = "opencode"
@@ -29,7 +33,10 @@ class OpenCodeProviderAdapter(HttpBearerProviderAdapter):
29
33
  configuration_defaults_value: dict = field(
30
34
  default_factory=lambda: provider_configuration(
31
35
  "claude-sonnet-4-6",
32
- custom_models=("claude-sonnet-4-6", "qwen3.6-plus-free"),
36
+ custom_models=(
37
+ "claude-sonnet-4-6",
38
+ OPENCODE_ZEN_OX_ALPHA_FREE_MODEL,
39
+ ),
33
40
  native_compat=True,
34
41
  context_window=200000,
35
42
  max_output_tokens=8192,
@@ -40,7 +47,7 @@ class OpenCodeProviderAdapter(HttpBearerProviderAdapter):
40
47
  ip_family="ipv6-preferred",
41
48
  haiku_model="claude-haiku-4-5",
42
49
  subagent_model="claude-sonnet-4-6",
43
- model_endpoints={},
50
+ model_endpoints={OPENCODE_ZEN_OX_ALPHA_FREE_MODEL: "openai-chat"},
44
51
  )
45
52
  )
46
53
  send_placeholder_key: bool = True
@@ -132,21 +139,30 @@ class OpenCodeProviderAdapter(HttpBearerProviderAdapter):
132
139
  normalized = normalized[len(prefix) :]
133
140
  break
134
141
  if self.name == "opencode-go":
142
+ if normalized.startswith(("gpt-", "grok-", "muse-spark-")):
143
+ return "openai_responses"
144
+ if normalized == OPENCODE_GO_OX_ALPHA_FREE_MODEL:
145
+ return "openai_chat"
146
+ if normalized == "hy3":
147
+ return "openai_chat"
135
148
  if normalized.startswith(("glm-", "kimi-", "deepseek-", "mimo-", "hy3-")):
136
149
  return "openai_chat"
137
150
  return "anthropic_messages"
138
- if normalized.startswith("gpt-"):
151
+ if normalized.startswith(("gpt-", "grok-", "muse-spark-")):
139
152
  return "openai_responses"
140
153
  if normalized.startswith("gemini-"):
141
154
  return "google_generative"
155
+ if normalized == OPENCODE_ZEN_OX_ALPHA_FREE_MODEL:
156
+ return "openai_chat"
142
157
  if normalized.startswith(
143
158
  (
144
159
  "minimax-",
145
160
  "glm-",
146
161
  "kimi-",
147
- "grok-",
148
162
  "big-pickle",
149
163
  "deepseek-",
164
+ "hy3-",
165
+ "laguna-",
150
166
  "mimo-",
151
167
  "nemotron-",
152
168
  "north-",
@@ -217,4 +233,8 @@ class OpenCodeProviderAdapter(HttpBearerProviderAdapter):
217
233
  return configuration_policy(supports_model_endpoint_overrides=True)
218
234
 
219
235
 
220
- __all__ = ["OpenCodeProviderAdapter"]
236
+ __all__ = [
237
+ "OPENCODE_GO_OX_ALPHA_FREE_MODEL",
238
+ "OPENCODE_ZEN_OX_ALPHA_FREE_MODEL",
239
+ "OpenCodeProviderAdapter",
240
+ ]
@@ -4,7 +4,7 @@ from dataclasses import dataclass, field
4
4
 
5
5
  from .base import provider_configuration
6
6
  from .constants import DEFAULT_REQUEST_TIMEOUT_MS, PROVIDER_DEFAULT_BASE_URLS
7
- from .opencode import OpenCodeProviderAdapter
7
+ from .opencode import OPENCODE_GO_OX_ALPHA_FREE_MODEL, OpenCodeProviderAdapter
8
8
 
9
9
 
10
10
  @dataclass(frozen=True)
@@ -14,7 +14,7 @@ class OpenCodeGoProviderAdapter(OpenCodeProviderAdapter):
14
14
  configuration_defaults_value: dict = field(
15
15
  default_factory=lambda: provider_configuration(
16
16
  "qwen3.6-plus",
17
- custom_models=("qwen3.6-plus",),
17
+ custom_models=("qwen3.6-plus", OPENCODE_GO_OX_ALPHA_FREE_MODEL),
18
18
  native_compat=True,
19
19
  context_window=1048576,
20
20
  max_output_tokens=8192,
@@ -25,7 +25,7 @@ class OpenCodeGoProviderAdapter(OpenCodeProviderAdapter):
25
25
  ip_family="ipv6-preferred",
26
26
  haiku_model="qwen3.5-plus",
27
27
  subagent_model="qwen3.6-plus",
28
- model_endpoints={},
28
+ model_endpoints={OPENCODE_GO_OX_ALPHA_FREE_MODEL: "openai-chat"},
29
29
  )
30
30
  )
31
31
  api_key_display_name_value: str = "OpenCode Go"
@@ -1,12 +1,23 @@
1
1
  """OpenRouter provider adapter."""
2
2
 
3
3
  from dataclasses import dataclass, field
4
+ from typing import Any, Mapping
4
5
 
5
- from ..architecture import ProviderCapabilities, ProviderConfig, ProviderContextPolicy
6
+ from ..architecture import (
7
+ MessageProtocol,
8
+ ProviderCapabilities,
9
+ ProviderConfig,
10
+ ProviderContextPolicy,
11
+ )
6
12
  from .base import OpenAICompatibleProviderAdapter, provider_configuration
7
13
  from .constants import DEFAULT_REQUEST_TIMEOUT_MS, PROVIDER_DEFAULT_BASE_URLS
8
14
 
9
15
 
16
+ OPENROUTER_OX_ALPHA_MODEL = "stealth/ox-alpha"
17
+ OPENROUTER_OX_ALPHA_CONTEXT_WINDOW = 1_048_576
18
+ OPENROUTER_OX_ALPHA_MAX_OUTPUT_TOKENS = 131_072
19
+
20
+
10
21
  @dataclass(frozen=True)
11
22
  class OpenRouterProviderAdapter(OpenAICompatibleProviderAdapter):
12
23
  name: str = "OpenRouter"
@@ -14,6 +25,7 @@ class OpenRouterProviderAdapter(OpenAICompatibleProviderAdapter):
14
25
  configuration_defaults_value: dict = field(
15
26
  default_factory=lambda: provider_configuration(
16
27
  "nvidia/nemotron-3-ultra-550b-a55b:free",
28
+ custom_models=(OPENROUTER_OX_ALPHA_MODEL,),
17
29
  native_compat=False,
18
30
  rate_limit_rpm=0,
19
31
  rate_limit_status=False,
@@ -50,8 +62,69 @@ class OpenRouterProviderAdapter(OpenAICompatibleProviderAdapter):
50
62
  def router_native_anthropic_enabled(
51
63
  self, config: ProviderConfig, model: str | None = None
52
64
  ) -> bool:
65
+ return self.select_protocol("anthropic_messages", config, model) == "anthropic_messages"
66
+
67
+ def supported_protocols(
68
+ self, config: ProviderConfig, model: str | None = None
69
+ ) -> frozenset[MessageProtocol]:
70
+ protocols: set[MessageProtocol] = {"openai_chat"}
71
+ selected = self.normalize_model_id(str(model or config.model or ""))
72
+ native = config.options.get("native_compat")
73
+ if selected == OPENROUTER_OX_ALPHA_MODEL or native is True or str(
74
+ native
75
+ ).strip().lower() in {"1", "true", "yes", "on"}:
76
+ protocols.add("anthropic_messages")
77
+ return frozenset(protocols)
78
+
79
+ def select_protocol(
80
+ self,
81
+ operation: MessageProtocol,
82
+ config: ProviderConfig,
83
+ model: str | None = None,
84
+ ) -> MessageProtocol:
85
+ if operation == "anthropic_messages" and "anthropic_messages" in self.supported_protocols(
86
+ config, model
87
+ ):
88
+ return "anthropic_messages"
89
+ return "openai_chat"
90
+
91
+ def model_configuration_profile(
92
+ self, config: ProviderConfig
93
+ ) -> tuple[Mapping[str, Any], str | None]:
94
+ if self.normalize_model_id(config.model) != OPENROUTER_OX_ALPHA_MODEL:
95
+ return {}, None
96
+ return (
97
+ {
98
+ "context_window": OPENROUTER_OX_ALPHA_CONTEXT_WINDOW,
99
+ "max_model_len": OPENROUTER_OX_ALPHA_CONTEXT_WINDOW,
100
+ "max_output_tokens": OPENROUTER_OX_ALPHA_MAX_OUTPUT_TOKENS,
101
+ "model_profile": "openrouter-ox-alpha-1m",
102
+ "supports_tool_choice": True,
103
+ "supports_vision": True,
104
+ },
105
+ "OpenRouter Ox Alpha profile applied: 1,048,576-token context and 131,072-token maximum output.",
106
+ )
107
+
108
+ def openai_reasoning_effort(
109
+ self,
110
+ config: ProviderConfig,
111
+ model: str,
112
+ request: Mapping[str, Any],
113
+ ) -> str | None:
53
114
  del config, model
54
- return False
115
+ metadata = request.get("metadata")
116
+ hinted = (
117
+ metadata.get("ciel_runtime_reasoning_effort")
118
+ if isinstance(metadata, Mapping)
119
+ else None
120
+ )
121
+ value = str(request.get("reasoning_effort") or hinted or "").strip().casefold()
122
+ return value or None
55
123
 
56
124
 
57
- __all__ = ["OpenRouterProviderAdapter"]
125
+ __all__ = [
126
+ "OPENROUTER_OX_ALPHA_CONTEXT_WINDOW",
127
+ "OPENROUTER_OX_ALPHA_MAX_OUTPUT_TOKENS",
128
+ "OPENROUTER_OX_ALPHA_MODEL",
129
+ "OpenRouterProviderAdapter",
130
+ ]
@@ -0,0 +1,130 @@
1
+ """TaBiAI (tabitoken.com) provider adapter."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Mapping
5
+
6
+ from ..architecture import (
7
+ MessageProtocol,
8
+ ProviderCapabilities,
9
+ ProviderConfig,
10
+ ProviderContextPolicy,
11
+ ProviderModelCatalogPolicy,
12
+ ProviderRequestPolicy,
13
+ )
14
+ from .base import OpenAICompatibleProviderAdapter, provider_configuration
15
+ from .constants import DEFAULT_REQUEST_TIMEOUT_MS, PROVIDER_DEFAULT_BASE_URLS
16
+
17
+
18
+ TABITOKEN_MODELS: tuple[str, ...] = (
19
+ "claude-opus-4-8",
20
+ "claude-opus-4-8-thinking",
21
+ "claude-opus-5",
22
+ "claude-opus-5-thinking",
23
+ )
24
+ TABITOKEN_THINKING_MODELS = frozenset(
25
+ model for model in TABITOKEN_MODELS if model.endswith("-thinking")
26
+ )
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class TabitokenProviderAdapter(OpenAICompatibleProviderAdapter):
31
+ """Dual OpenAI Chat and Anthropic Messages transport for TaBiAI."""
32
+
33
+ name: str = "tabitoken"
34
+ base_url: str = PROVIDER_DEFAULT_BASE_URLS["tabitoken"]
35
+ configuration_defaults_value: dict = field(
36
+ default_factory=lambda: provider_configuration(
37
+ TABITOKEN_MODELS[0],
38
+ custom_models=TABITOKEN_MODELS,
39
+ native_compat=True,
40
+ context_window=1_000_000,
41
+ max_output_tokens=8192,
42
+ context_reserve_tokens=8192,
43
+ request_timeout_ms=DEFAULT_REQUEST_TIMEOUT_MS,
44
+ stream_enabled=True,
45
+ stream_word_chunking=False,
46
+ )
47
+ )
48
+ authorization_header: str = "Authorization"
49
+ include_x_api_key: bool = False
50
+ require_api_key: bool = True
51
+ api_key_display_name_value: str = "TaBiAI (Tabitoken.com)"
52
+ api_key_launch_error_value: str = (
53
+ "Launch blocked: TaBiAI (Tabitoken.com) requires an API key."
54
+ )
55
+ capabilities_value: ProviderCapabilities = field(
56
+ default_factory=lambda: ProviderCapabilities(
57
+ upstream_protocol="openai_chat",
58
+ requires_api_key=True,
59
+ supports_thinking=True,
60
+ preserves_anthropic_thinking=True,
61
+ )
62
+ )
63
+ request_policy_value: ProviderRequestPolicy = field(
64
+ default_factory=lambda: ProviderRequestPolicy(
65
+ chat_path="/v1/chat/completions",
66
+ models_path="/v1/models",
67
+ )
68
+ )
69
+ model_catalog_policy_value: ProviderModelCatalogPolicy = field(
70
+ default_factory=lambda: ProviderModelCatalogPolicy(
71
+ kind="openai",
72
+ fallback_models=TABITOKEN_MODELS,
73
+ allow_configured_fallback=True,
74
+ )
75
+ )
76
+
77
+ def context_policy(self, config: ProviderConfig) -> ProviderContextPolicy:
78
+ del config
79
+ return ProviderContextPolicy(
80
+ capacity_strategy="configured_first",
81
+ settings_strategy="standard",
82
+ hosted_timeout=True,
83
+ )
84
+
85
+ def anthropic_base_url(self, config: ProviderConfig) -> str:
86
+ return str(config.base_url or self.default_base_url()).rstrip("/")
87
+
88
+ def supported_protocols(
89
+ self,
90
+ config: ProviderConfig,
91
+ model: str | None = None,
92
+ ) -> frozenset[MessageProtocol]:
93
+ del config, model
94
+ return frozenset({"anthropic_messages", "openai_chat"})
95
+
96
+ def select_protocol(
97
+ self,
98
+ operation: MessageProtocol,
99
+ config: ProviderConfig,
100
+ model: str | None = None,
101
+ ) -> MessageProtocol:
102
+ del config, model
103
+ if operation == "anthropic_messages":
104
+ return "anthropic_messages"
105
+ return "openai_chat"
106
+
107
+ def openai_reasoning_effort(
108
+ self,
109
+ config: ProviderConfig,
110
+ model: str,
111
+ request: Mapping[str, Any],
112
+ ) -> str | None:
113
+ del config
114
+ if self.normalize_model_id(model) not in TABITOKEN_THINKING_MODELS:
115
+ return None
116
+ metadata = request.get("metadata")
117
+ hinted = (
118
+ metadata.get("ciel_runtime_reasoning_effort")
119
+ if isinstance(metadata, Mapping)
120
+ else None
121
+ )
122
+ value = str(request.get("reasoning_effort") or hinted or "").strip().casefold()
123
+ return value or None
124
+
125
+
126
+ __all__ = [
127
+ "TABITOKEN_MODELS",
128
+ "TABITOKEN_THINKING_MODELS",
129
+ "TabitokenProviderAdapter",
130
+ ]
@@ -65,6 +65,13 @@ def target_file(workspace: Path, runtime: str) -> Path:
65
65
  return workspace.resolve() / filename
66
66
 
67
67
 
68
+ def normalized_instruction_sha256(value: str) -> str:
69
+ """Hash managed instruction text independently of platform line endings."""
70
+
71
+ normalized = value.replace("\r\n", "\n").replace("\r", "\n").strip()
72
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
73
+
74
+
68
75
  @dataclass(frozen=True, slots=True)
69
76
  class RemoteInstructionResult:
70
77
  runtime: str
@@ -104,10 +111,14 @@ class RemoteInstructionSynchronizer:
104
111
  return self._failed(runtime, url, f"missing authorization environment variable: {', '.join(sorted(set(missing)))}")
105
112
  if authorization.strip():
106
113
  headers["Authorization"] = authorization.strip()
107
- if state.get("etag"):
108
- headers["If-None-Match"] = str(state["etag"])
109
- if state.get("last_modified"):
110
- headers["If-Modified-Since"] = str(state["last_modified"])
114
+ # States written before normalized content verification cannot safely
115
+ # authorize pointer projection after platform newline conversion.
116
+ # Refresh them without validators once so the next state is verifiable.
117
+ if state.get("normalized_sha256"):
118
+ if state.get("etag"):
119
+ headers["If-None-Match"] = str(state["etag"])
120
+ if state.get("last_modified"):
121
+ headers["If-Modified-Since"] = str(state["last_modified"])
111
122
  request = urllib.request.Request(url, headers=headers, method="GET")
112
123
  try:
113
124
  with self.urlopen(request, timeout=timeout) as response:
@@ -126,6 +137,7 @@ class RemoteInstructionSynchronizer:
126
137
  "url": url,
127
138
  "target": path.name,
128
139
  "sha256": digest,
140
+ "normalized_sha256": normalized_instruction_sha256(text),
129
141
  "etag": str(response.headers.get("etag") or ""),
130
142
  "last_modified": str(response.headers.get("last-modified") or ""),
131
143
  "updated_at": self.now(),
@@ -162,14 +174,15 @@ class RemoteInstructionSynchronizer:
162
174
 
163
175
  @staticmethod
164
176
  def _write_if_changed(path: Path, text: str) -> bool:
177
+ raw = text.encode("utf-8")
165
178
  try:
166
- if path.read_text(encoding="utf-8") == text:
179
+ if path.read_bytes() == raw:
167
180
  return False
168
181
  except FileNotFoundError:
169
182
  pass
170
183
  path.parent.mkdir(parents=True, exist_ok=True)
171
184
  temporary = path.with_name(path.name + ".ciel-runtime.tmp")
172
- temporary.write_text(text, encoding="utf-8")
185
+ temporary.write_bytes(raw)
173
186
  os.replace(temporary, path)
174
187
  return True
175
188
 
@@ -237,6 +250,7 @@ __all__ = [
237
250
  "SynchronizedLaunch",
238
251
  "configured_url",
239
252
  "expand_environment_references",
253
+ "normalized_instruction_sha256",
240
254
  "panel_rows",
241
255
  "settings",
242
256
  "target_file",