@oneciel-ai/ciel-runtime 0.2.37 → 0.2.38

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 (140) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +5 -2
  3. package/ciel_runtime.py +94 -96
  4. package/ciel_runtime_support/anthropic_model_policy.py +20 -3
  5. package/ciel_runtime_support/architecture.py +5 -0
  6. package/ciel_runtime_support/channel_inflight.py +4 -4
  7. package/ciel_runtime_support/channel_injection.py +56 -1
  8. package/ciel_runtime_support/channel_mcp_tools.py +34 -0
  9. package/ciel_runtime_support/channel_message_policy.py +9 -1
  10. package/ciel_runtime_support/channel_message_prompt.py +23 -0
  11. package/ciel_runtime_support/channel_pending_injection.py +86 -32
  12. package/ciel_runtime_support/channel_pending_poll.py +6 -2
  13. package/ciel_runtime_support/channel_runtime_environment.py +0 -11
  14. package/ciel_runtime_support/channel_terminal_context.py +0 -6
  15. package/ciel_runtime_support/channel_terminal_proxy.py +3 -42
  16. package/ciel_runtime_support/channel_transcript_repository.py +16 -1
  17. package/ciel_runtime_support/channel_wake_claim_repository.py +0 -19
  18. package/ciel_runtime_support/channel_wake_context.py +6 -13
  19. package/ciel_runtime_support/channel_wake_delivery_repository.py +50 -0
  20. package/ciel_runtime_support/chat_http_controller.py +88 -5
  21. package/ciel_runtime_support/claude_environment.py +100 -3
  22. package/ciel_runtime_support/claude_launch_assembly.py +4 -0
  23. package/ciel_runtime_support/claude_session_socket.py +181 -0
  24. package/ciel_runtime_support/cli_application_context.py +18 -10
  25. package/ciel_runtime_support/cli_dispatch.py +19 -3
  26. package/ciel_runtime_support/cli_parser.py +2 -0
  27. package/ciel_runtime_support/cli_usage.py +3 -2
  28. package/ciel_runtime_support/codex_backend_context.py +8 -3
  29. package/ciel_runtime_support/codex_completion_gate.py +198 -0
  30. package/ciel_runtime_support/codex_turn_recovery.py +240 -83
  31. package/ciel_runtime_support/compatibility_protocol.py +5 -2
  32. package/ciel_runtime_support/config_migrations.py +79 -0
  33. package/ciel_runtime_support/config_repository.py +7 -0
  34. package/ciel_runtime_support/context_summary_policy.py +24 -4
  35. package/ciel_runtime_support/external_event_menu.py +107 -0
  36. package/ciel_runtime_support/external_event_receiver.py +19 -1
  37. package/ciel_runtime_support/launch_state.py +2 -0
  38. package/ciel_runtime_support/managed_tool_injection.py +33 -0
  39. package/ciel_runtime_support/muse_runtime_context.py +291 -0
  40. package/ciel_runtime_support/ollama_thinking.py +12 -26
  41. package/ciel_runtime_support/prelaunch.py +13 -0
  42. package/ciel_runtime_support/prelaunch_launch_panel.py +2 -1
  43. package/ciel_runtime_support/prompt_compaction.py +39 -0
  44. package/ciel_runtime_support/provider_files_proxy.py +248 -0
  45. package/ciel_runtime_support/provider_model_identity.py +10 -2
  46. package/ciel_runtime_support/provider_option_cli.py +1 -1
  47. package/ciel_runtime_support/provider_request_access.py +54 -1
  48. package/ciel_runtime_support/provider_responses_passthrough.py +152 -15
  49. package/ciel_runtime_support/providers/alibaba.py +34 -1
  50. package/ciel_runtime_support/providers/anthropic.py +18 -1
  51. package/ciel_runtime_support/providers/meta.py +252 -21
  52. package/ciel_runtime_support/pseudo_tool_parser.py +17 -1
  53. package/ciel_runtime_support/remote_bridge.py +4 -0
  54. package/ciel_runtime_support/remote_instructions.py +5 -0
  55. package/ciel_runtime_support/remote_memory.py +1 -1
  56. package/ciel_runtime_support/responses_cache_diagnostics.py +82 -0
  57. package/ciel_runtime_support/responses_custom_tool_bridge.py +282 -0
  58. package/ciel_runtime_support/responses_input_compatibility.py +19 -3
  59. package/ciel_runtime_support/router_http.py +301 -4
  60. package/ciel_runtime_support/router_observability_context.py +26 -0
  61. package/ciel_runtime_support/router_server_context.py +1 -0
  62. package/ciel_runtime_support/runtime_adapters.py +31 -0
  63. package/ciel_runtime_support/runtime_constants.py +23 -1
  64. package/ciel_runtime_support/runtime_input_gateway.py +85 -5
  65. package/ciel_runtime_support/runtime_input_status.py +150 -0
  66. package/ciel_runtime_support/runtime_launch.py +27 -4
  67. package/ciel_runtime_support/runtime_paths.py +1 -0
  68. package/ciel_runtime_support/speech_http_controller.py +3 -2
  69. package/ciel_runtime_support/streaming_anthropic.py +3 -0
  70. package/ciel_runtime_support/tool_call_events.py +98 -0
  71. package/ciel_runtime_support/transcript_delta_delivery.py +140 -1
  72. package/ciel_runtime_support/ui_text.py +1 -0
  73. package/ciel_runtime_support/web_search_result_events.py +130 -0
  74. package/ciel_runtime_support/web_ui.py +1 -0
  75. package/ciel_runtime_support/windows_conpty.py +92 -8
  76. package/ciel_runtime_support/workspace_mcp.py +4 -1
  77. package/ciel_runtime_support/workspace_state.py +1 -0
  78. package/docs/Configuration.md +20 -4
  79. package/docs/MCP-Channels.md +79 -6
  80. package/docs/Managed-Tool-Injection.md +25 -0
  81. package/docs/Module-Map.md +4 -0
  82. package/docs/Muse-Code.md +73 -0
  83. package/docs/Observability.md +39 -0
  84. package/docs/Providers.md +29 -2
  85. package/docs/Remote-Bridge.md +12 -0
  86. package/docs/Router.md +1 -0
  87. package/docs/journal/2026/09/01/diagnostics/anthropic/router/fable51-usage-credits.okf +70 -0
  88. package/docs/journal/2026/09/01/implementation/claude/session-socket/all-input-paths/default-delivery.okf +133 -0
  89. package/docs/journal/2026/09/01/implementation/claude/session-socket/windows-direct-input.okf +112 -0
  90. package/docs/journal/2026/09/02/diagnostics/cache/alibaba/qwen38-post-restart-hit-rate.okf +107 -0
  91. package/docs/journal/2026/09/02/diagnostics/cache/ollama-cloud/kimi-k3-codex-hit-rate.okf +91 -0
  92. package/docs/journal/2026/09/02/diagnostics/codex/tui/statusline-cache-metrics.okf +44 -0
  93. package/docs/journal/2026/09/02/diagnostics/input/transport/fallback-behavior.okf +31 -0
  94. package/docs/journal/2026/09/02/diagnostics/providers/ollama-cloud/kimi-k3-agent-turn-recovery.okf +125 -0
  95. package/docs/journal/2026/09/02/diagnostics/providers/ollama-cloud/kimi-k3-parameters.okf +128 -0
  96. package/docs/journal/2026/09/02/diagnostics/remote/mia/socket-tui-visibility.okf +47 -0
  97. package/docs/journal/2026/09/02/diagnostics/runtime/alibaba/token-plan/qwen38-latency.okf +108 -0
  98. package/docs/journal/2026/09/02/diagnostics/runtime/alibaba/token-plan/qwen38-vs-gpt56-latency.okf +92 -0
  99. package/docs/journal/2026/09/02/implementation/anthropic/context/one-million-defaults.okf +85 -0
  100. package/docs/journal/2026/09/02/implementation/cache/alibaba/qwen38-cache-hit-improvement.okf +104 -0
  101. package/docs/journal/2026/09/02/implementation/cache/alibaba/qwen38-cache-hit-improvement.png +0 -0
  102. package/docs/journal/2026/09/02/implementation/cache/alibaba/responses-session-cache.okf +97 -0
  103. package/docs/journal/2026/09/02/implementation/providers/alibaba/qwen38/0902-parameter-alignment.okf +97 -0
  104. package/docs/journal/2026/09/02/implementation/providers/meta/multimodal-tools/contributor-protocol-adoption.okf +140 -0
  105. package/docs/journal/2026/09/02/implementation/providers/meta/muse-spark-1.3-support.okf +53 -0
  106. package/docs/journal/2026/09/02/monitoring/cache/alibaba/cielarvis-qwen38-live-hit-rate.okf +858 -0
  107. package/docs/journal/2026/09/02/operations/release/main-merge-local-deploy.okf +36 -0
  108. package/docs/journal/2026/09/02/operations/release/nightly/local-cache-deployment.okf +54 -0
  109. package/docs/journal/2026/09/02/operations/release/nightly/ollama-kimi-recovery-model-audit-deployment.okf +58 -0
  110. package/docs/journal/2026/09/02/operations/release/nightly/qwen38-cache-improvement-deployment.okf +72 -0
  111. package/docs/journal/2026/09/02/research/cache/alibaba/qwen38-hit-rate-improvement.okf +233 -0
  112. package/docs/journal/2026/09/02/research/cache/codex/provider-scope.okf +77 -0
  113. package/docs/journal/2026/09/02/research/providers/ollama-cloud/desktop-model-exhaustive-audit.okf +159 -0
  114. package/docs/journal/2026/09/03/diagnostics/providers/meta/muse-spark-contributor-cache-hit.okf +148 -0
  115. package/docs/journal/2026/09/03/diagnostics/providers/meta/muse-spark-contributor-required-schema.okf +127 -0
  116. package/docs/journal/2026/09/03/diagnostics/providers/ollama-cloud/kimi-k3-resumed-session-stall.okf +160 -0
  117. package/docs/journal/2026/09/03/diagnostics/providers/ollama-cloud/kimi-k3-substantive-dangling-action.okf +94 -0
  118. package/docs/journal/2026/09/03/diagnostics/remote/kevin/codex-gpt-early-turn-completion.okf +162 -0
  119. package/docs/journal/2026/09/03/implementation/runtimes/meta/muse-code/default-yolo.okf +40 -0
  120. package/docs/journal/2026/09/03/implementation/runtimes/meta/muse-code/native-subscription-router.okf +86 -0
  121. package/docs/journal/2026/09/03/operations/deployment/local/session-socket-default-fallback.okf +41 -0
  122. package/docs/journal/2026/09/03/research/deployment/colab/tailscale/credential-storage.okf +41 -0
  123. package/docs/journal/2026/09/03/research/providers/meta/muse-code/subscription-billing-boundary.okf +64 -0
  124. package/docs/journal/2026/09/03/research/web-chat/voice/instruction-format.okf +135 -0
  125. package/docs/journal/2026/09/04/diagnostics/claude/context/early-auto-compaction.okf +75 -0
  126. package/docs/journal/2026/09/04/diagnostics/claude/context/fable-51-status-200k.okf +54 -0
  127. package/docs/journal/2026/09/04/diagnostics/runtime/codex/responses/replayed-item-id-validation.okf +74 -0
  128. package/docs/journal/2026/09/04/diagnostics/workspaces/onecieldmsui/codex/replay-stall.okf +100 -0
  129. package/docs/journal/2026/09/04/implementation/observability/tool-calls/websocket-stream.okf +49 -0
  130. package/docs/journal/2026/09/04/implementation/web-chat/input/raw-injection.okf +42 -0
  131. package/docs/journal/2026/09/05/diagnostics/tty/claude-raw-idle-render.okf +25 -0
  132. package/docs/journal/2026/09/05/implementation/events/search-response-urls.okf +23 -0
  133. package/docs/journal/2026/09/05/implementation/tools/native-injection.okf +19 -0
  134. package/docs/journal/2026/09/05/implementation/windows/conpty/prompt-delivery-lifecycle.okf +43 -0
  135. package/docs/journal/2026/09/05/release/nightly/conpty-native-tools.okf +13 -0
  136. package/docs/journal/2026/09/05/release/nightly/search-result-urls.okf +14 -0
  137. package/docs/journal/2026/09/06/implementation/codex/inherited-web-mcp.okf +22 -0
  138. package/docs/journal/2026/09/07/integration/main/nightly-merge.okf +13 -0
  139. package/docs/journal/2026/09/07/release/stable/0.2.38.okf +10 -0
  140. package/package.json +1 -1
@@ -15,6 +15,8 @@ import urllib.error
15
15
  import urllib.request
16
16
 
17
17
  from .remote_instructions import expand_environment_references
18
+ from .tool_call_events import project_transcript_tool_calls
19
+ from .web_search_result_events import project_web_search_results
18
20
 
19
21
 
20
22
  @dataclass(frozen=True, slots=True)
@@ -49,6 +51,34 @@ class TranscriptDeliverySettings:
49
51
  )
50
52
 
51
53
 
54
+ @dataclass(frozen=True, slots=True)
55
+ class ToolCallEventSettings:
56
+ enabled: bool
57
+ poll_interval_seconds: float
58
+ max_batch_bytes: int
59
+ start_mode: str
60
+ include_arguments: bool
61
+
62
+ @classmethod
63
+ def from_config(cls, config: dict[str, Any]) -> "ToolCallEventSettings":
64
+ raw = config.get("tool_call_events")
65
+ values = raw if isinstance(raw, dict) else {}
66
+ start_mode = str(values.get("start_mode") or "tail").strip().lower()
67
+ if start_mode not in {"tail", "beginning"}:
68
+ start_mode = "tail"
69
+ return cls(
70
+ enabled=bool(values.get("enabled", True)),
71
+ poll_interval_seconds=max(
72
+ 0.1, min(60.0, float(values.get("poll_interval_ms") or 500) / 1000.0)
73
+ ),
74
+ max_batch_bytes=max(
75
+ 1024, min(16_777_216, int(values.get("max_batch_bytes") or 1_048_576))
76
+ ),
77
+ start_mode=start_mode,
78
+ include_arguments=bool(values.get("include_arguments", True)),
79
+ )
80
+
81
+
52
82
  @dataclass(frozen=True, slots=True)
53
83
  class TranscriptDeliveryPorts:
54
84
  load_config: Callable[[], dict[str, Any]]
@@ -56,6 +86,8 @@ class TranscriptDeliveryPorts:
56
86
  scope: Callable[[], dict[str, Any]]
57
87
  log: Callable[[str, str], None]
58
88
  epoch: Callable[[], float] = time.time
89
+ event_publish: Callable[..., Any] = lambda **_kwargs: None
90
+ event_recent: Callable[..., list[dict[str, Any]]] = lambda **_kwargs: []
59
91
 
60
92
 
61
93
  class TranscriptDeltaDeliveryService:
@@ -153,6 +185,82 @@ class TranscriptDeltaDeliveryService:
153
185
  self._last_error = ""
154
186
  return True
155
187
 
188
+ def poll_tool_call_events(self) -> int:
189
+ settings = ToolCallEventSettings.from_config(self.ports.load_config())
190
+ if not settings.enabled:
191
+ return 0
192
+ path = self.ports.latest_transcript()
193
+ if path is None:
194
+ return 0
195
+ try:
196
+ path = path.resolve()
197
+ size = path.stat().st_size
198
+ except OSError:
199
+ return 0
200
+ scope = self.ports.scope()
201
+ runtime = str(scope.get("runtime") or "runtime")
202
+ session_id = str(scope.get("session_id") or path.stem)
203
+ source_key = hashlib.sha256(f"tool-call\0{path}".encode("utf-8")).hexdigest()
204
+ cursors = self._load_cursors()
205
+ sources = cursors.setdefault("tool_call_sources", {})
206
+ current = sources.get(source_key)
207
+ if not isinstance(current, dict):
208
+ offset = self._initial_tool_call_offset(settings, scope, path, size)
209
+ sources[source_key] = self._cursor_record(path, offset)
210
+ self._save_cursors(cursors)
211
+ if offset >= size:
212
+ return 0
213
+ current = sources[source_key]
214
+ offset = max(0, int(current.get("offset") or 0))
215
+ if size < offset:
216
+ offset = 0
217
+ current.pop("web_search_results", None)
218
+ payload = self._read_complete_batch(path, offset, settings.max_batch_bytes)
219
+ if not payload:
220
+ return 0
221
+ count = 0
222
+ search_state = current.setdefault("web_search_results", {})
223
+ for raw_line in payload.decode("utf-8", errors="replace").splitlines():
224
+ try:
225
+ record = json.loads(raw_line)
226
+ except (TypeError, ValueError):
227
+ continue
228
+ if not isinstance(record, dict):
229
+ continue
230
+ for result in project_web_search_results(record, runtime, search_state):
231
+ self.ports.event_publish(
232
+ level="info", category="tool.call", message=f"{runtime} web search result URLs",
233
+ source="cli-transcript", session_id=session_id, provider=runtime,
234
+ data=result,
235
+ )
236
+ count += 1
237
+ for call in project_transcript_tool_calls(record, runtime):
238
+ call_id = str(call.get("call_id") or "")
239
+ if call_id and any(
240
+ str((event.get("data") or {}).get("call_id") or "") == call_id
241
+ and (event.get("data") or {}).get("phase") != "result"
242
+ for event in self.ports.event_recent(limit=200, category="tool.call")
243
+ ):
244
+ continue
245
+ data = dict(call)
246
+ if not settings.include_arguments:
247
+ data.pop("arguments", None)
248
+ self.ports.event_publish(
249
+ level="info",
250
+ category="tool.call",
251
+ message=f"{runtime} tool call: {call['name']}",
252
+ source="cli-transcript",
253
+ session_id=session_id,
254
+ provider=runtime,
255
+ model=str(call.get("model") or ""),
256
+ data={**data, "transcript_name": path.name},
257
+ )
258
+ count += 1
259
+ sources[source_key] = self._cursor_record(path, offset + len(payload))
260
+ sources[source_key]["web_search_results"] = search_state
261
+ self._save_cursors(cursors)
262
+ return count
263
+
156
264
  @staticmethod
157
265
  def _initial_offset(
158
266
  settings: TranscriptDeliverySettings, scope: dict[str, Any], path: Path
@@ -175,7 +283,10 @@ class TranscriptDeltaDeliveryService:
175
283
  try:
176
284
  config = self.ports.load_config()
177
285
  settings = TranscriptDeliverySettings.from_config(config)
178
- interval = settings.poll_interval_seconds
286
+ tool_settings = ToolCallEventSettings.from_config(config)
287
+ interval = min(settings.poll_interval_seconds, tool_settings.poll_interval_seconds)
288
+ if tool_settings.enabled:
289
+ self.poll_tool_call_events()
179
290
  if settings.enabled and settings.url:
180
291
  self.poll_once()
181
292
  except Exception as exc:
@@ -191,9 +302,36 @@ class TranscriptDeltaDeliveryService:
191
302
  value = {}
192
303
  if not isinstance(value.get("destinations"), dict):
193
304
  value["destinations"] = {}
305
+ if not isinstance(value.get("tool_call_sources"), dict):
306
+ value["tool_call_sources"] = {}
194
307
  value["version"] = 1
195
308
  return value
196
309
 
310
+ @staticmethod
311
+ def _initial_tool_call_offset(
312
+ settings: ToolCallEventSettings,
313
+ scope: dict[str, Any],
314
+ path: Path,
315
+ size: int,
316
+ ) -> int:
317
+ if settings.start_mode == "beginning":
318
+ return 0
319
+ boundary_path = scope.get("turn_scan_path")
320
+ if boundary_path is not None:
321
+ try:
322
+ if Path(boundary_path).resolve() == path:
323
+ return max(0, int(scope.get("turn_scan_offset") or 0))
324
+ except (OSError, TypeError, ValueError):
325
+ pass
326
+ started_at = float(scope.get("started_at") or 0)
327
+ if started_at > 0:
328
+ try:
329
+ if path.stat().st_mtime >= started_at - 1.0:
330
+ return 0
331
+ except OSError:
332
+ pass
333
+ return size
334
+
197
335
  def _save_cursors(self, cursors: dict[str, Any]) -> None:
198
336
  self.cursor_path.parent.mkdir(parents=True, exist_ok=True)
199
337
  temporary = self.cursor_path.with_name(
@@ -330,4 +468,5 @@ __all__ = [
330
468
  "TranscriptDeliveryPorts",
331
469
  "TranscriptDeliverySettings",
332
470
  "TranscriptDeltaDeliveryService",
471
+ "ToolCallEventSettings",
333
472
  ]
@@ -256,6 +256,7 @@ PROVIDER_NOTES = {
256
256
 
257
257
  DEFAULT_ADVISOR_MODELS: tuple[str, ...] = (
258
258
  "",
259
+ "claude-fable-5-1",
259
260
  "claude-fable-5",
260
261
  "claude-opus-5",
261
262
  "claude-opus-4-8",
@@ -0,0 +1,130 @@
1
+ """Extract response URLs, never search arguments or result prose, from CLI records."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ from typing import Any
8
+ from urllib.parse import urlsplit
9
+
10
+
11
+ _SEARCH_NAMES = {"WebSearch", "web_search", "web.run", "web__run", "functions.web__run"}
12
+ _URL = re.compile(r"https?://[^\s<>\"\x00-\x1f]+")
13
+
14
+
15
+ def _is_search_call(value: dict[str, Any]) -> bool:
16
+ name = value.get("name")
17
+ if name not in _SEARCH_NAMES:
18
+ return False
19
+ if name in {"WebSearch", "web_search"}:
20
+ return True
21
+ arguments = value.get("arguments", value.get("input"))
22
+ if isinstance(arguments, str):
23
+ try:
24
+ arguments = json.loads(arguments)
25
+ except ValueError:
26
+ return False
27
+ return isinstance(arguments, dict) and bool(arguments.get("search_query"))
28
+
29
+
30
+ def _valid_url(value: Any) -> bool:
31
+ if not isinstance(value, str) or any(c.isspace() or ord(c) < 32 for c in value):
32
+ return False
33
+ try:
34
+ parsed = urlsplit(value)
35
+ return parsed.scheme in {"http", "https"} and bool(parsed.hostname) and not parsed.username and not parsed.password
36
+ except ValueError:
37
+ return False
38
+
39
+
40
+ def _urls(value: Any, *, text: bool = False) -> list[str]:
41
+ if isinstance(value, list):
42
+ return [url for item in value for url in _urls(item, text=text)]
43
+ if isinstance(value, dict):
44
+ result = [value["url"]] if _valid_url(value.get("url")) else []
45
+ for key in ("sources", "results", "content", "annotations", "url_citation"):
46
+ result.extend(_urls(value.get(key), text=text))
47
+ if text and value.get("type") == "text":
48
+ result.extend(_urls(value.get("text"), text=True))
49
+ return result
50
+ if text and isinstance(value, str):
51
+ # Claude Code supplies a JSON Links list embedded in a text result.
52
+ match = re.search(r"(?:^|\n)Links:\s*", value)
53
+ if match:
54
+ try:
55
+ links, _ = json.JSONDecoder().raw_decode(value[match.end():])
56
+ return _urls(links)
57
+ except ValueError:
58
+ return []
59
+ try:
60
+ decoded = json.loads(value)
61
+ except ValueError:
62
+ decoded = None
63
+ if isinstance(decoded, (dict, list)):
64
+ return _urls(decoded, text=True)
65
+ result = []
66
+ for match in _URL.finditer(value):
67
+ url = match.group().rstrip(".,;!?'\"]}")
68
+ while url.endswith(")") and url.count(")") > url.count("("):
69
+ url = url[:-1]
70
+ if _valid_url(url):
71
+ result.append(url)
72
+ return result
73
+ return []
74
+
75
+
76
+ def project_web_search_results(record: dict[str, Any], runtime: str, state: dict[str, Any]) -> list[dict[str, Any]]:
77
+ """State is persisted with the transcript offset, bounded to 512 calls/results."""
78
+ pending = state.setdefault("pending", {})
79
+ seen = state.setdefault("seen", [])
80
+ events = []
81
+
82
+ def emit(call_id: str, name: str, urls: list[str]) -> None:
83
+ urls = list(dict.fromkeys(urls))
84
+ if not urls:
85
+ return
86
+ digest = hashlib.sha256(json.dumps([call_id, urls], ensure_ascii=False).encode()).hexdigest()
87
+ if digest in seen:
88
+ return
89
+ seen.append(digest)
90
+ events.append({"call_id": call_id, "name": name, "runtime": runtime, "phase": "result", "urls": urls})
91
+
92
+ message = record.get("message") or {}
93
+ blocks = message.get("content", []) if isinstance(message, dict) else []
94
+ if isinstance(blocks, list):
95
+ for block in blocks:
96
+ if not isinstance(block, dict):
97
+ continue
98
+ kind = block.get("type")
99
+ if kind in {"tool_use", "server_tool_use"} and (record.get("type") == "assistant" or message.get("role") == "assistant") and _is_search_call(block):
100
+ if block.get("id"):
101
+ pending[str(block["id"])] = str(block["name"])
102
+ elif kind in {"tool_result", "web_search_tool_result"} and not block.get("is_error"):
103
+ call_id = str(block.get("tool_use_id") or "")
104
+ name = pending.get(call_id)
105
+ if name or kind == "web_search_tool_result":
106
+ emit(call_id, name or "web_search", _urls(block.get("content"), text=True))
107
+
108
+ payload = record.get("payload") or {}
109
+ if record.get("type") == "response_item" and isinstance(payload, dict):
110
+ kind = payload.get("type")
111
+ call_id = str(payload.get("call_id") or payload.get("id") or "")
112
+ if kind in {"function_call", "custom_tool_call"} and _is_search_call(payload) and call_id:
113
+ pending[call_id] = str(payload["name"])
114
+ elif kind in {"function_call_output", "custom_tool_call_output"} and call_id in pending:
115
+ emit(call_id, pending[call_id], _urls(payload.get("output"), text=True))
116
+ elif kind == "web_search_call" and payload.get("status") == "completed":
117
+ # Do not treat action.query or open_page input URLs as search results.
118
+ action = payload.get("action") or {}
119
+ sources = action.get("sources") if isinstance(action, dict) else None
120
+ emit(call_id, "web_search", _urls([sources, payload.get("results")]))
121
+ elif kind == "message" and payload.get("role") == "assistant":
122
+ # API-backed transcripts can retain structured URL citations.
123
+ for block in payload.get("content") or []:
124
+ if isinstance(block, dict):
125
+ annotations = [a for a in block.get("annotations", []) if isinstance(a, dict) and a.get("type") == "url_citation"]
126
+ emit(call_id, "web_search", _urls(annotations))
127
+ while len(pending) > 512:
128
+ del pending[next(iter(pending))]
129
+ del seen[:-512]
130
+ return events
@@ -1756,6 +1756,7 @@ def render_web_chat_page(
1756
1756
  sender_id: 'web-user',
1757
1757
  recipients: ['all'],
1758
1758
  delivery: ['llm', 'native'],
1759
+ input_transport: 'session_socket',
1759
1760
  thread_id: sessionId,
1760
1761
  kind: 'web_chat',
1761
1762
  message: outboundText,
@@ -29,6 +29,10 @@ _CLAUDE_COLLAPSED_PASTE_PATTERN = re.compile(
29
29
  rb"\[Pasted(?:\x1b\[[0-?]*[ -/]*[@-~]|\s)+"
30
30
  rb"text(?:\x1b\[[0-?]*[ -/]*[@-~]|\s)+#"
31
31
  )
32
+ _VT_OSC_PATTERN = re.compile(rb"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
33
+ _VT_CURSOR_RIGHT_PATTERN = re.compile(rb"\x1b\[([0-9]*)(?:C|a)")
34
+ _VT_CSI_PATTERN = re.compile(rb"\x1b\[[0-?]*[ -/]*[@-~]")
35
+ _VT_SINGLE_ESCAPE_PATTERN = re.compile(rb"\x1b[@-_]")
32
36
 
33
37
 
34
38
  def _collapsed_paste_marker_count(output: bytes | str) -> int:
@@ -38,6 +42,21 @@ def _collapsed_paste_marker_count(output: bytes | str) -> int:
38
42
  )
39
43
 
40
44
 
45
+ def _visible_terminal_text(output: bytes | str) -> str:
46
+ """Project ConPTY output to comparable visible text without mutating input."""
47
+
48
+ data = output.encode("utf-8", errors="replace") if isinstance(output, str) else bytes(output)
49
+ data = _VT_OSC_PATTERN.sub(b"", data)
50
+ data = _VT_CURSOR_RIGHT_PATTERN.sub(
51
+ lambda match: b" " * max(1, min(4096, int(match.group(1) or b"1"))),
52
+ data,
53
+ )
54
+ data = _VT_CSI_PATTERN.sub(b"", data)
55
+ data = _VT_SINGLE_ESCAPE_PATTERN.sub(b"", data)
56
+ text = data.decode("utf-8", errors="replace")
57
+ return " ".join(text.replace("\x00", "").split())
58
+
59
+
41
60
  def conpty_enabled(
42
61
  environment: Mapping[str, str] | None = None,
43
62
  *,
@@ -202,8 +221,9 @@ class WindowsConPtySession:
202
221
  prompt = WindowsConPtySession.normalize_prompt(str(expected_prompt or ""))
203
222
  if not prompt:
204
223
  return bool(output)
205
- prefix = prompt[:48].encode("utf-8", errors="replace")
206
- return bool(prefix and prefix in output) or _collapsed_paste_marker_count(output) > 0
224
+ prefix = _visible_terminal_text(prompt)[:48]
225
+ visible = _visible_terminal_text(output)
226
+ return bool(prefix and prefix in visible) or _collapsed_paste_marker_count(output) > 0
207
227
 
208
228
  @staticmethod
209
229
  def _prompt_rendered_since(
@@ -214,13 +234,44 @@ class WindowsConPtySession:
214
234
  if current == baseline:
215
235
  return False
216
236
  prompt = WindowsConPtySession.normalize_prompt(str(expected_prompt or ""))
217
- prefix = prompt[:48]
218
- if prefix and current.count(prefix) > baseline.count(prefix):
237
+ prefix = _visible_terminal_text(prompt)[:48]
238
+ current_visible = _visible_terminal_text(current)
239
+ baseline_visible = _visible_terminal_text(baseline)
240
+ if prefix and current_visible.count(prefix) > baseline_visible.count(prefix):
219
241
  return True
220
242
  if prompt and _collapsed_paste_marker_count(current) > _collapsed_paste_marker_count(baseline):
221
243
  return True
222
244
  return not prompt
223
245
 
246
+ def clear_unsubmitted_prompt(
247
+ self,
248
+ clear_input: bytes,
249
+ expected_prompt: str,
250
+ timeout_seconds: float = 2.0,
251
+ ) -> bool:
252
+ """Clear a draft and require a settled child redraw before it is retry-safe."""
253
+
254
+ checkpoint = self.prompt_readiness_checkpoint()
255
+ self.write(clear_input)
256
+ cursor = checkpoint
257
+ observed = bytearray()
258
+ stable_since: float | None = None
259
+ deadline = time.monotonic() + max(0.0, float(timeout_seconds))
260
+ prefix = _visible_terminal_text(self.normalize_prompt(expected_prompt))[:48]
261
+ while True:
262
+ now = time.monotonic()
263
+ chunk, cursor = self._output_since(cursor)
264
+ if chunk:
265
+ observed.extend(chunk)
266
+ del observed[: max(0, len(observed) - 64 * 1024)]
267
+ stable_since = now
268
+ if observed and stable_since is not None and now - stable_since >= 0.25:
269
+ visible = _visible_terminal_text(bytes(observed))
270
+ return bool(visible) and (not prefix or prefix not in visible)
271
+ if now >= deadline:
272
+ return False
273
+ time.sleep(0.02)
274
+
224
275
  @staticmethod
225
276
  def pending_input_events() -> None:
226
277
  return None
@@ -320,16 +371,49 @@ class WindowsConPtySession:
320
371
  def kill(self) -> None:
321
372
  self.terminate()
322
373
 
323
- def resize_if_needed(self) -> None:
374
+ def resize_if_needed(self) -> bool:
324
375
  if not self._hpc or not self._kernel32:
325
- return
376
+ return False
326
377
  size = self._terminal_size()
327
378
  if size == self._last_size:
328
- return
379
+ return False
380
+ checkpoint = self.prompt_readiness_checkpoint()
329
381
  coord = self._coord_type(size[0], size[1])
330
- result = int(self._kernel32.ResizePseudoConsole(self._hpc, coord))
382
+ try:
383
+ result = int(self._kernel32.ResizePseudoConsole(self._hpc, coord))
384
+ except Exception as exc:
385
+ self._log(
386
+ "ERROR",
387
+ "channel_windows_conpty_resize_failed "
388
+ f"cols={size[0]} rows={size[1]} error={type(exc).__name__}: {exc}",
389
+ )
390
+ return False
331
391
  if result == 0:
332
392
  self._last_size = size
393
+ redrawn = self._wait_for_output_change(checkpoint, 0.75)
394
+ self._log(
395
+ "INFO" if redrawn else "WARN",
396
+ "channel_windows_conpty_resize "
397
+ f"cols={size[0]} rows={size[1]} "
398
+ f"redraw={'observed' if redrawn else 'timeout'}",
399
+ )
400
+ return True
401
+ self._log(
402
+ "ERROR",
403
+ "channel_windows_conpty_resize_failed "
404
+ f"cols={size[0]} rows={size[1]} hresult=0x{result & 0xFFFFFFFF:08X}",
405
+ )
406
+ return False
407
+
408
+ def _wait_for_output_change(self, checkpoint: int, timeout_seconds: float) -> bool:
409
+ deadline = time.monotonic() + max(0.0, float(timeout_seconds))
410
+ while True:
411
+ chunk, _cursor = self._output_since(checkpoint)
412
+ if chunk:
413
+ return True
414
+ if time.monotonic() >= deadline:
415
+ return False
416
+ time.sleep(0.02)
333
417
 
334
418
  def close(self) -> None:
335
419
  if self._closed:
@@ -14,6 +14,8 @@ import time
14
14
  import uuid
15
15
  from typing import Any, Callable
16
16
 
17
+ from .managed_tool_injection import select_managed_tools
18
+
17
19
 
18
20
  _SERVER_ID = re.compile(r"^[A-Za-z0-9_-]{1,80}$")
19
21
  _RUNTIMES = frozenset({"claude", "codex", "codex-app-server"})
@@ -247,13 +249,14 @@ class WorkspaceMcpLaunchService:
247
249
  config: dict[str, Any],
248
250
  environment: Mapping[str, str] | None = None,
249
251
  injected_servers: Mapping[str, Any] | None = None,
252
+ *, native: bool = False,
250
253
  ) -> WorkspaceMcpLaunch:
251
254
  if runtime not in _RUNTIMES:
252
255
  raise ValueError(f"Unsupported workspace MCP runtime: {runtime}")
253
256
  self.recover_stale()
254
257
  servers = workspace_mcp_servers(config, runtime)
255
258
  if injected_servers:
256
- injected = {"workspace_mcp": {"servers": dict(injected_servers)}}
259
+ injected = {"workspace_mcp": {"servers": select_managed_tools(injected_servers, native=native)}}
257
260
  servers.update(workspace_mcp_servers(injected, runtime))
258
261
  if not servers:
259
262
  return WorkspaceMcpLaunch()
@@ -10,6 +10,7 @@ from pathlib import Path
10
10
  WORKSPACE_FILES = (
11
11
  "chat-messages.jsonl",
12
12
  "runtime-inputs.jsonl",
13
+ "runtime-input-status.jsonl",
13
14
  "channel-llm-cursor.json",
14
15
  "channel-llm-clear-floor.json",
15
16
  "channel-llm-launch-guard.json",
@@ -35,6 +35,8 @@
35
35
  | `model-registry.json` | 모델 레지스트리 |
36
36
  | `ollama-model-catalog.json` | Ollama 모델 카탈로그 캐시 (TTL: 24시간) |
37
37
  | `chat-messages.jsonl` | 채팅 메시지 (최대 20MB) |
38
+ | `workspaces/<workspace-id>/runtime-inputs.jsonl` | TUI/세션 소켓으로 전달할 private 입력 큐 |
39
+ | `workspaces/<workspace-id>/runtime-input-status.jsonl` | private 입력의 queued/submitted/replied/failed 상태 이력 |
38
40
  | `channel-probe-cache.json` | 채널 프로브 캐시 |
39
41
  | `launch-state.json` | 실행 상태 |
40
42
  | `tts-reference-audio/*.bin` | 업로드한 TTS reference의 private binary sidecar (`0600` 시도) |
@@ -235,6 +237,13 @@ Alibaba Model Studio Token Plan(`alitoken`)의 native Responses endpoint는 실
235
237
  10 MiB를 넘는 단일 비축약 항목은 upstream으로 보내거나 재시도하지 않고 로컬 413으로 종료한다.
236
238
  `alitoken-individual`과 다른 provider에는 이 wire 정책을 적용하지 않는다.
237
239
 
240
+ Alibaba의 Responses session cache는 요청 header가 없으면 기본적으로 비활성화된다.
241
+ `alims-intl`과 `alitoken`의 `openai_responses` 요청에는
242
+ `x-dashscope-session-cache: enable`을 기본 전송한다. 이 값은 provider의
243
+ `protocol_headers.openai_responses` 설정으로 관리되며 Chat/Anthropic wire에는 복사하지
244
+ 않는다. 이 범용 protocol header 설정은 `authorization`, API key, cookie, host,
245
+ content-length 및 hop-by-hop header를 덮어쓸 수 없다.
246
+
238
247
  ---
239
248
 
240
249
  ## 원격 시스템 지침
@@ -393,20 +402,27 @@ VT 활성화가 실패한 classic/legacy console에는 escape 문자열을 raw t
393
402
  연기한다. 따라서 wake 주입 앞의 `Ctrl+U`가 사용자가 먼저 입력한 글자를 지우지
394
403
  않으며, 사용자가 Enter로 draft를 제출한 뒤 기존 polling 절차가 다시 진행된다.
395
404
 
405
+ ConPTY prompt-render detector는 캡처된 UTF-8 출력을 기준으로 ANSI CSI/OSC/SGR을
406
+ 가시 텍스트로 투영한 뒤 원문 prefix를 비교한다. 따라서 한글 바이트와 ANSI 색상·
407
+ cursor 이동 시퀀스가 섞여도 화면에 표시된 원문을 감지한다. 이 투영은 감지에만
408
+ 사용하며 실제 입력 원문에는 표식이나 다른 문자를 추가하지 않는다.
409
+
396
410
  ConPTY 생성이 불가능하거나 `CIEL_RUNTIME_WINDOWS_CONPTY=0`으로 명시적으로 끈
397
411
  경우에만 기존 Windows Console 입력 큐 호환 경로를 사용한다. 이 호환 경로에서는
398
412
  `clear → body → submit`을 각각 큐가 소비한 뒤 진행하고, 여러 줄의 외부 메시지는
399
413
  줄바꿈이 Enter 키로 해석되지 않도록 한 줄로 정규화한다. 완전한 wake prompt 전달
400
- 확인이 반복해서 실패하면 같은 본문을 무한 재주입하지 않고 짧은 sentinel을
401
- 제출한 실제 메시지를 request-body 경로로 전달한다.
414
+ 확인에 실패하면 `Ctrl+U`로 해당 draft 제거를 요청하고 ConPTY redraw로 제거를
415
+ 확인한다. 제거 여부와 관계없이 같은 원문을 자동 재입력하지 않으며 요청 상태를
416
+ `failed`로 유지한다. 앞선 실패 요청은 다음 입력을 차단한다. ASCII sentinel을
417
+ 추가하거나 router request-body 전송으로 자동 변경하지 않는다.
402
418
 
403
419
  호환 경로는 실행 중 Win32 `ENABLE_MOUSE_INPUT` bit를 끄고 종료할 때 원래 입력
404
420
  mode를 복원한다. `CIEL_RUNTIME_WINDOWS_CONSOLE_MOUSE_FILTER=0`은 이 Win32 guard를,
405
421
  `CIEL_RUNTIME_TERMINAL_INPUT_MODE_RESET=0`은 안전한 VT mode reset을 각각 끈다.
406
422
  정확한 `ESC[20~`는 Microsoft VT 입력 표의 F9이므로 mouse report로 제거하지 않는다.
407
423
 
408
- `CIEL_RUNTIME_WINDOWS_CHANNEL_WAKE_MAX_ATTEMPTS`로 완전한 wake prompt 시도 상한을
409
- 설정할 있다. 기본값은 `2`, 허용 범위는 `1`~`4`이다.
424
+ ConPTY resize 실패는 요청 크기와 HRESULT/예외를 router 로그에 기록한다. 성공한
425
+ resize 뒤에는 child output 발생 여부를 확인해 `redraw=observed|timeout`을 남긴다.
410
426
 
411
427
  ---
412
428