cloudagent-daemon 0.3.0__tar.gz → 0.3.1__tar.gz

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 (83) hide show
  1. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/.gitignore +3 -0
  2. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/PKG-INFO +1 -1
  3. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/pyproject.toml +1 -1
  4. cloudagent_daemon-0.3.1/src/cloudagent_daemon/__init__.py +1 -0
  5. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/claude_runner.py +308 -8
  6. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/runtime_detect.py +1 -0
  7. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_claude_runner.py +457 -0
  8. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_runtime_detect.py +24 -1
  9. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/uv.lock +2 -2
  10. cloudagent_daemon-0.3.0/src/cloudagent_daemon/__init__.py +0 -1
  11. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/LICENSE +0 -0
  12. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/README.md +0 -0
  13. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/__main__.py +0 -0
  14. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/cli.py +0 -0
  15. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/config.py +0 -0
  16. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/context_fetch.py +0 -0
  17. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/delegation_report.py +0 -0
  18. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/doctor.py +0 -0
  19. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/frame_dispatch.py +0 -0
  20. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/http_outbound.py +0 -0
  21. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/injection_state.py +0 -0
  22. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  23. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/login.py +0 -0
  24. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/main_loop.py +0 -0
  25. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/mcp_bridge.py +0 -0
  26. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/metrics.py +0 -0
  27. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/metrics_server.py +0 -0
  28. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/outbound_queue.py +0 -0
  29. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/paths.py +0 -0
  30. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/protocol.py +0 -0
  31. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/service.py +0 -0
  32. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  33. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/spawn_queue.py +0 -0
  34. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/sse_client.py +0 -0
  35. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/transport.py +0 -0
  36. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/unix_socket.py +0 -0
  37. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/workspace.py +0 -0
  38. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/src/cloudagent_daemon/ws_client.py +0 -0
  39. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/__init__.py +0 -0
  40. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/_fake_sse_server.py +0 -0
  41. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/conftest.py +0 -0
  42. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_e2e_deliver_to_spawn.py +0 -0
  43. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_e2e_sse.py +0 -0
  44. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_full_e2e.py +0 -0
  45. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_mcp_bridge_round_trip.py +0 -0
  46. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_one_shot_cli.py +0 -0
  47. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_spawn_mcp_bridge_e2e.py +0 -0
  48. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_sse_handshake.py +0 -0
  49. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_sse_watchdog.py +0 -0
  50. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_ws_handshake.py +0 -0
  51. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/integration/test_ws_reconnect.py +0 -0
  52. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/conftest.py +0 -0
  53. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_claude_runner_routing_contract.py +0 -0
  54. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_claude_runner_w3.py +0 -0
  55. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_cli_multi_profile.py +0 -0
  56. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_cli_smoke.py +0 -0
  57. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_config.py +0 -0
  58. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_context_fetch.py +0 -0
  59. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_delegation_report.py +0 -0
  60. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_doctor.py +0 -0
  61. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_frame_dispatch.py +0 -0
  62. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_http_outbound.py +0 -0
  63. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_injection_state.py +0 -0
  64. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_jsonl_logging.py +0 -0
  65. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_login.py +0 -0
  66. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_main_loop_assign.py +0 -0
  67. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_mcp_bridge_send_message.py +0 -0
  68. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_metrics.py +0 -0
  69. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_metrics_server.py +0 -0
  70. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_multi_tool_wake.py +0 -0
  71. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_outbound_queue.py +0 -0
  72. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
  73. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_paths.py +0 -0
  74. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_protocol.py +0 -0
  75. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_service.py +0 -0
  76. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_service_multi_profile.py +0 -0
  77. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_spawn_outbox.py +0 -0
  78. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_spawn_queue.py +0 -0
  79. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_sse_active_watchdog.py +0 -0
  80. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_status_stop.py +0 -0
  81. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_workspace.py +0 -0
  82. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_workspace_root_task113.py +0 -0
  83. {cloudagent_daemon-0.3.0 → cloudagent_daemon-0.3.1}/tests/unit/test_ws_client_insecure.py +0 -0
@@ -89,3 +89,6 @@ package-lock.json
89
89
 
90
90
  # M7 设计 demo 的解压产物(源在 Opc.zip,解压即得)
91
91
  company/m7/refactors/Opc_extracted/
92
+
93
+ # understand-anything tool cache
94
+ .understand-anything/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudagent-daemon
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket.
5
5
  Project-URL: Homepage, https://cloudagent.chat
6
6
  Project-URL: Repository, https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cloudagent-daemon"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket."
9
9
  authors = [{name = "chaoyin", email = "tudoucyx@gmail.com"}]
10
10
  readme = "README.md"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.1"
@@ -33,6 +33,9 @@ from __future__ import annotations
33
33
  import asyncio
34
34
  import json
35
35
  import logging
36
+ import os
37
+ import platform
38
+ import subprocess
36
39
  import time
37
40
  from dataclasses import dataclass
38
41
  from pathlib import Path
@@ -60,6 +63,165 @@ PartialPublisher = Callable[[str, str, str], Awaitable[None]]
60
63
  logger = logging.getLogger(__name__)
61
64
 
62
65
 
66
+ _RUNTIME_PROXY_URL_ENV = "CLOUDAGENT_RUNTIME_PROXY_URL"
67
+ _RUNTIME_NO_PROXY_ENV = "CLOUDAGENT_RUNTIME_NO_PROXY"
68
+ _DISABLE_RUNTIME_PROXY_ENV = "CLOUDAGENT_DISABLE_RUNTIME_PROXY"
69
+ _DEFAULT_RUNTIME_NO_PROXY = "localhost,127.0.0.1,::1"
70
+ _PROXY_ENV_VARS = (
71
+ "HTTP_PROXY",
72
+ "HTTPS_PROXY",
73
+ "ALL_PROXY",
74
+ "http_proxy",
75
+ "https_proxy",
76
+ "all_proxy",
77
+ )
78
+
79
+
80
+ def _truthy_env(value: str | None) -> bool:
81
+ return value is not None and value.strip().lower() in {
82
+ "1",
83
+ "true",
84
+ "yes",
85
+ "on",
86
+ }
87
+
88
+
89
+ def _disabled_proxy_value(value: str | None) -> bool:
90
+ return value is not None and value.strip().lower() in {
91
+ "0",
92
+ "false",
93
+ "no",
94
+ "off",
95
+ "none",
96
+ "direct",
97
+ }
98
+
99
+
100
+ def _parse_scutil_proxy_output(text: str) -> dict[str, str]:
101
+ out: dict[str, str] = {}
102
+ for raw_line in text.splitlines():
103
+ line = raw_line.strip()
104
+ if " : " not in line:
105
+ continue
106
+ key, value = line.split(" : ", 1)
107
+ out[key.strip()] = value.strip()
108
+ return out
109
+
110
+
111
+ def _proxy_url_from_parts(
112
+ scheme: str, host: str | None, port: str | None
113
+ ) -> str | None:
114
+ host = (host or "").strip()
115
+ port = (port or "").strip()
116
+ if not host or not port:
117
+ return None
118
+ if "://" in host:
119
+ return host
120
+ return f"{scheme}://{host}:{port}"
121
+
122
+
123
+ def _detect_macos_system_proxy_url() -> str | None:
124
+ """Return the active macOS proxy URL for runtime subprocesses.
125
+
126
+ launchd jobs do not inherit Network Settings proxy configuration as
127
+ environment variables. Codex/Claude child runtimes, however, rely on
128
+ standard proxy env vars when they need to reach model providers from
129
+ restricted networks. Read the system proxy at spawn time so existing
130
+ installed daemons start working after upgrade + restart.
131
+ """
132
+ if platform.system().lower() != "darwin":
133
+ return None
134
+ try:
135
+ result = subprocess.run(
136
+ ["/usr/sbin/scutil", "--proxy"],
137
+ capture_output=True,
138
+ text=True,
139
+ timeout=2.0,
140
+ check=False,
141
+ )
142
+ except (OSError, subprocess.TimeoutExpired):
143
+ return None
144
+ if result.returncode != 0:
145
+ return None
146
+
147
+ values = _parse_scutil_proxy_output(result.stdout)
148
+ if values.get("HTTPSEnable") == "1":
149
+ proxy = _proxy_url_from_parts(
150
+ "http", values.get("HTTPSProxy"), values.get("HTTPSPort")
151
+ )
152
+ if proxy:
153
+ return proxy
154
+ if values.get("HTTPEnable") == "1":
155
+ proxy = _proxy_url_from_parts(
156
+ "http", values.get("HTTPProxy"), values.get("HTTPPort")
157
+ )
158
+ if proxy:
159
+ return proxy
160
+ if values.get("SOCKSEnable") == "1":
161
+ return _proxy_url_from_parts(
162
+ "socks5h", values.get("SOCKSProxy"), values.get("SOCKSPort")
163
+ )
164
+ return None
165
+
166
+
167
+ def _runtime_proxy_url(env: dict[str, str]) -> str | None:
168
+ explicit = env.get(_RUNTIME_PROXY_URL_ENV)
169
+ if _disabled_proxy_value(explicit):
170
+ return None
171
+ if explicit and explicit.strip():
172
+ return explicit.strip()
173
+ for name in (
174
+ "HTTPS_PROXY",
175
+ "https_proxy",
176
+ "HTTP_PROXY",
177
+ "http_proxy",
178
+ "ALL_PROXY",
179
+ "all_proxy",
180
+ ):
181
+ value = env.get(name)
182
+ if value and value.strip():
183
+ return value.strip()
184
+ return _detect_macos_system_proxy_url()
185
+
186
+
187
+ def _runtime_subprocess_env(
188
+ base_env: dict[str, str] | None = None,
189
+ ) -> dict[str, str] | None:
190
+ """Build env for Codex/Claude subprocesses.
191
+
192
+ The daemon itself deliberately bypasses ambient system proxies when it
193
+ connects to CloudAgent. Runtime children are different: they often must
194
+ reach external model-provider endpoints such as chatgpt.com or
195
+ Anthropic. If a runtime proxy is configured or discoverable from macOS
196
+ Network Settings, inject proxy env vars for the child only and narrow
197
+ ``NO_PROXY=*`` so the runtime does not accidentally bypass the proxy.
198
+ """
199
+ env = dict(base_env or os.environ)
200
+ if _truthy_env(env.get(_DISABLE_RUNTIME_PROXY_ENV)):
201
+ return env
202
+
203
+ proxy_url = _runtime_proxy_url(env)
204
+ if not proxy_url:
205
+ return None
206
+
207
+ for name in _PROXY_ENV_VARS:
208
+ env[name] = proxy_url
209
+
210
+ runtime_no_proxy = env.get(_RUNTIME_NO_PROXY_ENV)
211
+ if runtime_no_proxy is not None:
212
+ no_proxy = runtime_no_proxy
213
+ else:
214
+ upper = env.get("NO_PROXY")
215
+ lower = env.get("no_proxy")
216
+ if upper in (None, "", "*") or lower in (None, "", "*"):
217
+ no_proxy = _DEFAULT_RUNTIME_NO_PROXY
218
+ else:
219
+ no_proxy = upper or lower or _DEFAULT_RUNTIME_NO_PROXY
220
+ env["NO_PROXY"] = no_proxy
221
+ env["no_proxy"] = no_proxy
222
+ return env
223
+
224
+
63
225
  # ---------------------------------------------------------------------------
64
226
  # Workspace protocol — concrete impl arrives in Task 11
65
227
  # ---------------------------------------------------------------------------
@@ -142,6 +304,15 @@ class _SpawnAttempt:
142
304
  event). Both feed the Layer-B daemon-side fallback that POSTs a
143
305
  ``send_message`` on claude's behalf when claude generated a reply
144
306
  but never invoked the tool.
307
+
308
+ M8 — additionally tracks ``had_api_error``: True iff claude emitted
309
+ an ``isApiErrorMessage:true`` synthetic assistant event during the
310
+ wake. Claude Code's CLI handles upstream Anthropic/Bedrock API
311
+ errors by printing a synthetic ``assistant`` message (model=
312
+ ``<synthetic>``, ``isApiErrorMessage:true``) and exiting rc=0
313
+ *without* a terminating ``type=result`` event — so neither rc nor
314
+ ``final_result_text`` reflects the failure. This flag lets the
315
+ runner promote the wake to ``crashed`` instead of ``idle``.
145
316
  """
146
317
 
147
318
  exit_code: int
@@ -154,6 +325,9 @@ class _SpawnAttempt:
154
325
  # ``type=result`` event (normalised to ``_INITIAL_USAGE`` shape).
155
326
  # ``None`` when the result event carried no ``usage`` blob.
156
327
  result_usage: dict | None
328
+ # M8 — set True when claude emitted an ``isApiErrorMessage:true``
329
+ # event during the wake (upstream API error swallowed by the CLI).
330
+ had_api_error: bool = False
157
331
 
158
332
 
159
333
  def _build_lazy_identity(agent_id: str, payload: dict) -> dict:
@@ -512,6 +686,14 @@ The daemon discards stdout — only the mcp tool call reaches the user.
512
686
 
513
687
  if timeout_hit:
514
688
  final_status = "timeout"
689
+ elif attempt.had_api_error:
690
+ # M8 — Claude Code swallows upstream Anthropic/Bedrock API
691
+ # errors: it prints a synthetic ``isApiErrorMessage:true``
692
+ # event and exits rc=0 *without* a terminating ``type=
693
+ # result``. Without this branch the wake would be reported
694
+ # as ``idle`` (cleanly completed) — leaving the dispatching
695
+ # chief silently waiting for a reply that will never come.
696
+ final_status = "crashed"
515
697
  elif rc == 0:
516
698
  final_status = "idle"
517
699
  else:
@@ -534,6 +716,76 @@ The daemon discards stdout — only the mcp tool call reaches the user.
534
716
  exc_info=True,
535
717
  )
536
718
 
719
+ # M8 — wake_aborted escalation. When the wake ends in a non-idle
720
+ # terminal state AND the inbound message was one whose sender is
721
+ # waiting on a reply (kind in WAIT_FOR_REPLIES_VALID_PARENT_KINDS:
722
+ # task / peer_request / question) AND claude never managed to
723
+ # call send_message, post a system-marked reply on its behalf so
724
+ # the chief's wait_for_replies satisfies early instead of timing
725
+ # out 80+ minutes later. This is NOT a fake reply: the body is
726
+ # explicitly tagged ``[system: ...]`` so the receiving agent can
727
+ # distinguish daemon-emitted abort signals from real staff work.
728
+ # Best-effort: any failure here is logged but never crashes the
729
+ # wake (the wake's user-facing contract is already satisfied by
730
+ # the agent_status frame below).
731
+ WAKE_REPLY_EXPECTED_KINDS = ("task", "peer_request", "question")
732
+ inbound_kind = payload.get("kind")
733
+ parent_for_abort = (
734
+ payload.get("parent_msg_id") or payload.get("message_id")
735
+ )
736
+ if (
737
+ final_status in ("crashed", "timeout")
738
+ and not called_send_message
739
+ and isinstance(fallback_session, str)
740
+ and fallback_session
741
+ and inbound_kind in WAKE_REPLY_EXPECTED_KINDS
742
+ and parent_for_abort not in (None, "")
743
+ ):
744
+ if attempt.had_api_error:
745
+ reason = "upstream_api_error"
746
+ elif timeout_hit:
747
+ reason = "wake_timeout"
748
+ else:
749
+ reason = "claude_crashed"
750
+ abort_body = (
751
+ f"[system: wake_aborted reason={reason} — the dispatched "
752
+ f"agent did not produce a reply (claude exit_code={rc}). "
753
+ f"This is a daemon-emitted system signal, not staff "
754
+ f"output. Re-dispatch or escalate as needed.]"
755
+ )
756
+ logger.warning(
757
+ "claude_runner: wake_aborted — POSTing system reply "
758
+ "(agent=%s session=%s reason=%s parent_msg_id=%s)",
759
+ agent_id,
760
+ fallback_session,
761
+ reason,
762
+ parent_for_abort,
763
+ )
764
+ try:
765
+ await self.outbound.send_tool_call(
766
+ agent_id=agent_id,
767
+ session_id=fallback_session,
768
+ tool_name="send_message",
769
+ args=self._fallback_reply_args(payload, abort_body),
770
+ timeout=15.0,
771
+ )
772
+ try:
773
+ from cloudagent_daemon.metrics import get_default as _md
774
+ await _md().inc_counter(
775
+ "cloudagent_daemon_wake_aborted_emitted_total",
776
+ labels={"reason": reason},
777
+ )
778
+ except Exception: # noqa: BLE001
779
+ pass
780
+ except Exception: # pragma: no cover — best-effort
781
+ logger.exception(
782
+ "claude_runner: wake_aborted POST FAILED "
783
+ "(chief will time out) agent=%s session=%s reason=%s",
784
+ agent_id,
785
+ fallback_session,
786
+ reason,
787
+ )
788
+
537
789
  await self.outbound.send_agent_status(
538
790
  agent_id, final_status, exit_code=rc
539
791
  )
@@ -763,12 +1015,22 @@ The daemon discards stdout — only the mcp tool call reaches the user.
763
1015
  )
764
1016
 
765
1017
  logger.debug("ClaudeRunner: spawning %s", cmd)
1018
+ spawn_env = _runtime_subprocess_env()
1019
+ if spawn_env is not None:
1020
+ logger.info(
1021
+ "claude_runner: applying runtime proxy env to subprocess "
1022
+ "agent=%s runtime=%s no_proxy=%s",
1023
+ agent_id,
1024
+ runtime_engine,
1025
+ spawn_env.get("NO_PROXY", ""),
1026
+ )
766
1027
  proc = await asyncio.create_subprocess_exec(
767
1028
  *cmd,
768
1029
  stdin=asyncio.subprocess.PIPE,
769
1030
  stdout=asyncio.subprocess.PIPE,
770
1031
  stderr=asyncio.subprocess.PIPE,
771
1032
  cwd=str(spawn_cwd),
1033
+ env=spawn_env,
772
1034
  # asyncio StreamReader 默认 limit=64KB;claude CLI 单条 JSON
773
1035
  # event 可超出(tool_result 含大型 Read 文件 / Anthropic 返回
774
1036
  # body / partial_messages 累积 chunk 等)。2026-05-14 实战:
@@ -819,6 +1081,7 @@ The daemon discards stdout — only the mcp tool call reaches the user.
819
1081
  tool_uses,
820
1082
  final_text,
821
1083
  result_usage,
1084
+ had_api_error,
822
1085
  ) = await self._consume_stdout(
823
1086
  proc,
824
1087
  parse_mode=(
@@ -848,6 +1111,7 @@ The daemon discards stdout — only the mcp tool call reaches the user.
848
1111
  tool_uses=tool_uses,
849
1112
  final_result_text=final_text,
850
1113
  result_usage=result_usage,
1114
+ had_api_error=had_api_error,
851
1115
  )
852
1116
 
853
1117
  # ------------------------------------------------------------------
@@ -994,7 +1258,9 @@ Available cloudagent tools:
994
1258
  identity = {}
995
1259
  raw = identity.get("runtime_engine")
996
1260
  engine = raw.strip() if isinstance(raw, str) else ""
997
- return "codex" if engine == "codex" else "claude_code"
1261
+ if engine in {"codex", "mulerun"}:
1262
+ return engine
1263
+ return "claude_code"
998
1264
 
999
1265
  def _fallback_reply_args(self, payload: dict, body: str) -> dict:
1000
1266
  """Build daemon fallback ``send_message`` args.
@@ -1086,7 +1352,10 @@ Available cloudagent tools:
1086
1352
  ]
1087
1353
  return cmd
1088
1354
 
1089
- cmd = [self.runtime_binaries.get("claude_code") or self.claude_binary]
1355
+ if runtime_engine == "mulerun":
1356
+ cmd = [self.runtime_binaries.get("mulerun") or "mulerun", "code", "--"]
1357
+ else:
1358
+ cmd = [self.runtime_binaries.get("claude_code") or self.claude_binary]
1090
1359
  if last_session:
1091
1360
  cmd += ["--resume", last_session]
1092
1361
  # 1i Bug F — inject operational contract via --append-system-prompt.
@@ -1135,7 +1404,7 @@ Available cloudagent tools:
1135
1404
  *,
1136
1405
  partial_session_id: str | None = None,
1137
1406
  partial_agent_id: str | None = None,
1138
- ) -> tuple[str | None, list[str], str | None, dict | None]:
1407
+ ) -> tuple[str | None, list[str], str | None, dict | None, bool]:
1139
1408
  """Consume ``codex exec --json`` NDJSON.
1140
1409
 
1141
1410
  Codex emits ``thread.started`` with ``thread_id`` and one or more
@@ -1216,7 +1485,9 @@ Available cloudagent tools:
1216
1485
  logger.warning("CodexRunner: stdout error event=%s msg=%r", evt_type, msg)
1217
1486
 
1218
1487
  final_text = "\n\n".join(final_chunks) if final_chunks else None
1219
- return captured, [], final_text, result_usage
1488
+ # Codex doesn't surface upstream API errors as a synthetic event
1489
+ # the way Claude Code does — leave had_api_error=False here.
1490
+ return captured, [], final_text, result_usage, False
1220
1491
 
1221
1492
  async def _consume_stdout(
1222
1493
  self,
@@ -1225,8 +1496,8 @@ Available cloudagent tools:
1225
1496
  parse_mode: str = "stream_json",
1226
1497
  partial_session_id: str | None = None,
1227
1498
  partial_agent_id: str | None = None,
1228
- ) -> tuple[str | None, list[str], str | None, dict | None]:
1229
- """Stream NDJSON from stdout; return ``(session_id, tool_uses, result_text, result_usage)``.
1499
+ ) -> tuple[str | None, list[str], str | None, dict | None, bool]:
1500
+ """Stream NDJSON; return ``(session_id, tool_uses, result_text, result_usage, had_api_error)``.
1230
1501
 
1231
1502
  Accepts BOTH session-init event shapes for forward/backward
1232
1503
  compatibility:
@@ -1274,6 +1545,9 @@ Available cloudagent tools:
1274
1545
  final_text: str | None = None
1275
1546
  # M7 W3 — whole-turn usage from the terminating ``result`` event.
1276
1547
  result_usage: dict | None = None
1548
+ # M8 — set True when claude emits a synthetic isApiErrorMessage
1549
+ # assistant event (upstream API error swallowed by the CLI).
1550
+ had_api_error: bool = False
1277
1551
  # 1k74 — running buffer for token-level streaming. claude emits
1278
1552
  # one ``stream_event`` per content_block_delta (~80 chars at a
1279
1553
  # time); we concatenate them and forward the running total to
@@ -1290,7 +1564,7 @@ Available cloudagent tools:
1290
1564
  _cache_read_tokens: int = 0
1291
1565
  _cache_creation_tokens: int = 0
1292
1566
  if proc.stdout is None:
1293
- return None, tool_uses, final_text, result_usage
1567
+ return None, tool_uses, final_text, result_usage, had_api_error
1294
1568
 
1295
1569
  async for line_b in proc.stdout:
1296
1570
  line = line_b.decode("utf-8", errors="replace").strip()
@@ -1384,6 +1658,32 @@ Available cloudagent tools:
1384
1658
  # {"type":"tool_use","name":"mcp__cloudagent__send_message",...}
1385
1659
  # ]}}
1386
1660
  if evt_type == "assistant":
1661
+ # M8 — detect synthetic API error events. Claude Code's
1662
+ # CLI flags upstream Anthropic/Bedrock errors with
1663
+ # ``isApiErrorMessage:true`` (model=``<synthetic>``) and
1664
+ # then exits rc=0 *without* a terminating ``type=result``
1665
+ # event. Without this signal the runner would mark the
1666
+ # wake ``idle`` and the dispatcher would never know the
1667
+ # reply was lost.
1668
+ if evt.get("isApiErrorMessage") is True:
1669
+ had_api_error = True
1670
+ err_text = ""
1671
+ msg_x = evt.get("message")
1672
+ if isinstance(msg_x, dict):
1673
+ for blk in msg_x.get("content") or []:
1674
+ if (
1675
+ isinstance(blk, dict)
1676
+ and blk.get("type") == "text"
1677
+ ):
1678
+ t = blk.get("text")
1679
+ if isinstance(t, str):
1680
+ err_text = t[:200]
1681
+ break
1682
+ logger.warning(
1683
+ "ClaudeRunner: stdout isApiErrorMessage event — "
1684
+ "wake will be marked crashed (text=%r)",
1685
+ err_text,
1686
+ )
1387
1687
  msg = evt.get("message")
1388
1688
  content = (
1389
1689
  msg.get("content")
@@ -1470,7 +1770,7 @@ Available cloudagent tools:
1470
1770
  "claude_runner: cache metrics publish failed", exc_info=True
1471
1771
  )
1472
1772
 
1473
- return captured, tool_uses, final_text, result_usage
1773
+ return captured, tool_uses, final_text, result_usage, had_api_error
1474
1774
 
1475
1775
  async def _wait_with_timeout(
1476
1776
  self, proc: asyncio.subprocess.Process
@@ -34,6 +34,7 @@ class DetectedRuntime:
34
34
  # (canonical_name, [binary candidates in priority order], [version-args])
35
35
  _PROBES: list[tuple[str, list[str], list[str]]] = [
36
36
  ("claude_code", ["claude", "claude-code"], ["--version"]),
37
+ ("mulerun", ["mulerun"], ["--version"]),
37
38
  ("codex", ["codex"], ["--version"]),
38
39
  ("opencode", ["opencode", "open-code"], ["version"]),
39
40
  ("aider", ["aider"], ["--version"]),