cloudagent-daemon 0.2.6__tar.gz → 0.3.0__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.
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/.gitignore +3 -1
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/PKG-INFO +1 -1
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/pyproject.toml +1 -1
- cloudagent_daemon-0.3.0/src/cloudagent_daemon/__init__.py +1 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/claude_runner.py +421 -18
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/cli.py +5 -4
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/config.py +3 -3
- cloudagent_daemon-0.3.0/src/cloudagent_daemon/context_fetch.py +336 -0
- cloudagent_daemon-0.3.0/src/cloudagent_daemon/delegation_report.py +338 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/frame_dispatch.py +19 -1
- cloudagent_daemon-0.3.0/src/cloudagent_daemon/http_outbound.py +185 -0
- cloudagent_daemon-0.3.0/src/cloudagent_daemon/injection_state.py +144 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/main_loop.py +87 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/paths.py +28 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/protocol.py +3 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/service.py +8 -10
- cloudagent_daemon-0.3.0/tests/integration/__init__.py +0 -0
- cloudagent_daemon-0.3.0/tests/integration/_fake_sse_server.py +360 -0
- cloudagent_daemon-0.3.0/tests/integration/conftest.py +248 -0
- cloudagent_daemon-0.3.0/tests/integration/test_e2e_deliver_to_spawn.py +290 -0
- cloudagent_daemon-0.3.0/tests/integration/test_e2e_sse.py +266 -0
- cloudagent_daemon-0.3.0/tests/integration/test_full_e2e.py +336 -0
- cloudagent_daemon-0.3.0/tests/integration/test_mcp_bridge_round_trip.py +477 -0
- cloudagent_daemon-0.3.0/tests/integration/test_one_shot_cli.py +321 -0
- cloudagent_daemon-0.3.0/tests/integration/test_spawn_mcp_bridge_e2e.py +309 -0
- cloudagent_daemon-0.3.0/tests/integration/test_sse_handshake.py +204 -0
- cloudagent_daemon-0.3.0/tests/integration/test_sse_watchdog.py +120 -0
- cloudagent_daemon-0.3.0/tests/integration/test_ws_handshake.py +156 -0
- cloudagent_daemon-0.3.0/tests/integration/test_ws_reconnect.py +300 -0
- cloudagent_daemon-0.3.0/tests/unit/conftest.py +25 -0
- cloudagent_daemon-0.3.0/tests/unit/test_claude_runner.py +1939 -0
- cloudagent_daemon-0.3.0/tests/unit/test_claude_runner_routing_contract.py +68 -0
- cloudagent_daemon-0.3.0/tests/unit/test_claude_runner_w3.py +473 -0
- cloudagent_daemon-0.3.0/tests/unit/test_cli_multi_profile.py +199 -0
- cloudagent_daemon-0.3.0/tests/unit/test_cli_smoke.py +169 -0
- cloudagent_daemon-0.3.0/tests/unit/test_config.py +323 -0
- cloudagent_daemon-0.3.0/tests/unit/test_context_fetch.py +296 -0
- cloudagent_daemon-0.3.0/tests/unit/test_delegation_report.py +282 -0
- cloudagent_daemon-0.3.0/tests/unit/test_doctor.py +300 -0
- cloudagent_daemon-0.3.0/tests/unit/test_frame_dispatch.py +484 -0
- cloudagent_daemon-0.3.0/tests/unit/test_http_outbound.py +367 -0
- cloudagent_daemon-0.3.0/tests/unit/test_injection_state.py +93 -0
- cloudagent_daemon-0.3.0/tests/unit/test_jsonl_logging.py +110 -0
- cloudagent_daemon-0.3.0/tests/unit/test_login.py +165 -0
- cloudagent_daemon-0.3.0/tests/unit/test_main_loop_assign.py +316 -0
- cloudagent_daemon-0.3.0/tests/unit/test_mcp_bridge_send_message.py +133 -0
- cloudagent_daemon-0.3.0/tests/unit/test_metrics.py +143 -0
- cloudagent_daemon-0.3.0/tests/unit/test_metrics_server.py +118 -0
- cloudagent_daemon-0.3.0/tests/unit/test_multi_tool_wake.py +382 -0
- cloudagent_daemon-0.3.0/tests/unit/test_outbound_queue.py +176 -0
- cloudagent_daemon-0.3.0/tests/unit/test_outbox_stuck_watchdog.py +393 -0
- cloudagent_daemon-0.3.0/tests/unit/test_paths.py +267 -0
- cloudagent_daemon-0.3.0/tests/unit/test_protocol.py +166 -0
- cloudagent_daemon-0.3.0/tests/unit/test_runtime_detect.py +236 -0
- cloudagent_daemon-0.3.0/tests/unit/test_service.py +308 -0
- cloudagent_daemon-0.3.0/tests/unit/test_service_multi_profile.py +457 -0
- cloudagent_daemon-0.3.0/tests/unit/test_spawn_outbox.py +295 -0
- cloudagent_daemon-0.3.0/tests/unit/test_spawn_queue.py +300 -0
- cloudagent_daemon-0.3.0/tests/unit/test_sse_active_watchdog.py +175 -0
- cloudagent_daemon-0.3.0/tests/unit/test_status_stop.py +170 -0
- cloudagent_daemon-0.3.0/tests/unit/test_workspace.py +580 -0
- cloudagent_daemon-0.3.0/tests/unit/test_workspace_root_task113.py +127 -0
- cloudagent_daemon-0.3.0/tests/unit/test_ws_client_insecure.py +49 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/uv.lock +1 -1
- cloudagent_daemon-0.2.6/src/cloudagent_daemon/__init__.py +0 -1
- cloudagent_daemon-0.2.6/src/cloudagent_daemon/http_outbound.py +0 -80
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/LICENSE +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/README.md +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/__main__.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/doctor.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/jsonl_logging.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/login.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/mcp_bridge.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/metrics.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/metrics_server.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/outbound_queue.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/runtime_detect.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/spawn_outbox.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/spawn_queue.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/sse_client.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/transport.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/unix_socket.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/workspace.py +0 -0
- {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/ws_client.py +0 -0
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
# Local work-in-progress scratch dir (never committed)
|
|
11
11
|
abu/
|
|
12
|
+
cc_codex/
|
|
12
13
|
|
|
13
14
|
# GSD workflow tool config (local only)
|
|
14
15
|
.planning/
|
|
@@ -85,5 +86,6 @@ package-lock.json
|
|
|
85
86
|
|
|
86
87
|
# playwright 测试相关
|
|
87
88
|
.playwright-mcp/
|
|
88
|
-
tests/
|
|
89
89
|
|
|
90
|
+
# M7 设计 demo 的解压产物(源在 Opc.zip,解压即得)
|
|
91
|
+
company/m7/refactors/Opc_extracted/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cloudagent-daemon
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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.
|
|
7
|
+
version = "0.3.0"
|
|
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.0"
|
|
@@ -121,6 +121,9 @@ class SpawnResult:
|
|
|
121
121
|
exit_code: int
|
|
122
122
|
captured_session_id: str | None
|
|
123
123
|
stderr_tail: str
|
|
124
|
+
# M7 W3 — whole-turn token usage (``_INITIAL_USAGE`` shape) parsed
|
|
125
|
+
# from the Claude CLI ``type=result`` event. ``None`` when absent.
|
|
126
|
+
result_usage: dict | None = None
|
|
124
127
|
|
|
125
128
|
|
|
126
129
|
@dataclass
|
|
@@ -147,6 +150,10 @@ class _SpawnAttempt:
|
|
|
147
150
|
timeout_hit: bool
|
|
148
151
|
tool_uses: list[str]
|
|
149
152
|
final_result_text: str | None
|
|
153
|
+
# M7 W3 — whole-turn token usage parsed from the Claude CLI
|
|
154
|
+
# ``type=result`` event (normalised to ``_INITIAL_USAGE`` shape).
|
|
155
|
+
# ``None`` when the result event carried no ``usage`` blob.
|
|
156
|
+
result_usage: dict | None
|
|
150
157
|
|
|
151
158
|
|
|
152
159
|
def _build_lazy_identity(agent_id: str, payload: dict) -> dict:
|
|
@@ -171,6 +178,7 @@ def _build_lazy_identity(agent_id: str, payload: dict) -> dict:
|
|
|
171
178
|
"role": str(payload.get("role") or "staff"),
|
|
172
179
|
"workspace_slug": str(payload.get("workspace_slug") or "default"),
|
|
173
180
|
"reports_to": "(unset)",
|
|
181
|
+
"runtime_engine": str(payload.get("runtime_engine") or "claude_code"),
|
|
174
182
|
}
|
|
175
183
|
|
|
176
184
|
|
|
@@ -198,18 +206,34 @@ class ClaudeRunner:
|
|
|
198
206
|
single_wake_timeout: float | None = None,
|
|
199
207
|
partial_publisher: PartialPublisher | None = None,
|
|
200
208
|
agent_to_session: "dict[str, str | None] | None" = None,
|
|
201
|
-
claude_permission_mode: str = "
|
|
209
|
+
claude_permission_mode: str = "bypassPermissions",
|
|
210
|
+
runtime_binaries: dict[str, str] | None = None,
|
|
211
|
+
http: object | None = None,
|
|
212
|
+
injection_state: object | None = None,
|
|
213
|
+
delegation_outbox: object | None = None,
|
|
202
214
|
) -> None:
|
|
203
215
|
self.outbound = outbound
|
|
204
216
|
self.workspace = workspace
|
|
205
217
|
self.claude_binary = claude_binary
|
|
206
|
-
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
218
|
+
# M7 W3 — localagent context injection + delegation reporting.
|
|
219
|
+
# ``http`` is the shared HTTPOutbound (GET /v1/daemon/task-context
|
|
220
|
+
# + POST /v1/daemon/delegation-status). ``injection_state`` is the
|
|
221
|
+
# file-backed (agent,task) dedup store. ``delegation_outbox`` is
|
|
222
|
+
# the durable journal for undelivered status reports. All three
|
|
223
|
+
# are ``None`` in the legacy / test path — the runner then simply
|
|
224
|
+
# skips injection + reporting (pre-M7 behaviour, no crash).
|
|
225
|
+
self._http = http
|
|
226
|
+
self._injection_state = injection_state
|
|
227
|
+
self._delegation_outbox = delegation_outbox
|
|
228
|
+
# Claude CLI ``--permission-mode``. The daemon default is now
|
|
229
|
+
# ``bypassPermissions`` so local agents can run build/git/file
|
|
230
|
+
# workflows unattended. "default" remains supported as an
|
|
231
|
+
# explicit opt-out. See DaemonConfig.claude_permission_mode for
|
|
232
|
+
# the per-mode contract.
|
|
210
233
|
self.claude_permission_mode = claude_permission_mode
|
|
211
234
|
# Allow tests to override the 90-min timeout without monkey-patching
|
|
212
235
|
# a class attribute. Defaults to spec value.
|
|
236
|
+
self.runtime_binaries = runtime_binaries or {}
|
|
213
237
|
self._single_wake_timeout: float = (
|
|
214
238
|
single_wake_timeout
|
|
215
239
|
if single_wake_timeout is not None
|
|
@@ -345,6 +369,13 @@ The daemon discards stdout — only the mcp tool call reaches the user.
|
|
|
345
369
|
if self._agent_to_session is not None:
|
|
346
370
|
self._agent_to_session[agent_id] = _effective_session_id
|
|
347
371
|
|
|
372
|
+
# M7 W3 — localagent company-context injection. BEFORE spawning
|
|
373
|
+
# claude, if this deliver belongs to a task whose context has not
|
|
374
|
+
# yet been pulled onto this host, fetch + write the company
|
|
375
|
+
# rules / skills / memory into the agent workspace. Best-effort:
|
|
376
|
+
# never crashes the wake (see _maybe_inject_task_context).
|
|
377
|
+
await self._maybe_inject_task_context(agent_id, ws, payload)
|
|
378
|
+
|
|
348
379
|
wake_start = time.monotonic()
|
|
349
380
|
try:
|
|
350
381
|
attempt = await self._spawn_attempt(
|
|
@@ -450,11 +481,9 @@ The daemon discards stdout — only the mcp tool call reaches the user.
|
|
|
450
481
|
agent_id=agent_id,
|
|
451
482
|
session_id=fallback_session,
|
|
452
483
|
tool_name="send_message",
|
|
453
|
-
args=
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
"kind": "reply",
|
|
457
|
-
},
|
|
484
|
+
args=self._fallback_reply_args(
|
|
485
|
+
payload, attempt.final_result_text
|
|
486
|
+
),
|
|
458
487
|
# Backend dispatch is fast — short timeout keeps the
|
|
459
488
|
# wake from blocking the spawn queue if the WS round
|
|
460
489
|
# trip stalls.
|
|
@@ -521,12 +550,164 @@ The daemon discards stdout — only the mcp tool call reaches the user.
|
|
|
521
550
|
len(stderr_tail),
|
|
522
551
|
)
|
|
523
552
|
|
|
553
|
+
# M7 W3 — delegation status + token-usage report. When this
|
|
554
|
+
# deliver carried a ``delegation_id`` the work is a delegation;
|
|
555
|
+
# report the outcome (completed/failed) + whole-turn usage to the
|
|
556
|
+
# backend so the ``delegations`` row gets filled. Best-effort:
|
|
557
|
+
# never crashes the wake (see _maybe_report_delegation).
|
|
558
|
+
await self._maybe_report_delegation(
|
|
559
|
+
payload,
|
|
560
|
+
timeout_hit=timeout_hit,
|
|
561
|
+
exit_code=rc,
|
|
562
|
+
result_usage=attempt.result_usage,
|
|
563
|
+
final_result_text=attempt.final_result_text,
|
|
564
|
+
)
|
|
565
|
+
|
|
524
566
|
return SpawnResult(
|
|
525
567
|
exit_code=rc,
|
|
526
568
|
captured_session_id=captured_session,
|
|
527
569
|
stderr_tail=stderr_tail,
|
|
570
|
+
result_usage=attempt.result_usage,
|
|
528
571
|
)
|
|
529
572
|
|
|
573
|
+
# ------------------------------------------------------------------
|
|
574
|
+
# M7 W3 — localagent context injection + delegation reporting
|
|
575
|
+
# ------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
async def _maybe_inject_task_context(
|
|
578
|
+
self, agent_id: str, ws: AgentWorkspaceLike, payload: dict
|
|
579
|
+
) -> None:
|
|
580
|
+
"""Pull + write company context for a task's first delegation.
|
|
581
|
+
|
|
582
|
+
No-op when:
|
|
583
|
+
* the runner has no ``http`` / ``injection_state`` wired
|
|
584
|
+
(legacy / test path);
|
|
585
|
+
* the deliver frame carries no ``task_id`` (old backend that
|
|
586
|
+
doesn't stamp it — compat matrix: new daemon + old backend);
|
|
587
|
+
* this ``(agent_id, task_id)`` was already injected (the file-
|
|
588
|
+
backed marker survives daemon restarts → acceptance W3-04:
|
|
589
|
+
the second delegation of the same task does NOT re-pull).
|
|
590
|
+
|
|
591
|
+
On the first delegation of a task it fetches
|
|
592
|
+
``GET /v1/daemon/task-context`` and writes CLAUDE.md / skills /
|
|
593
|
+
memory into the agent workspace, then records the marker so
|
|
594
|
+
subsequent delegations skip the pull.
|
|
595
|
+
|
|
596
|
+
NEVER raises — a failed injection must not crash or block the
|
|
597
|
+
wake (acceptance W3-06). The agent simply runs on the user's
|
|
598
|
+
own ``~/.claude`` (pre-M7 behaviour).
|
|
599
|
+
"""
|
|
600
|
+
if self._http is None or self._injection_state is None:
|
|
601
|
+
return
|
|
602
|
+
task_id = payload.get("task_id")
|
|
603
|
+
task_id = task_id if isinstance(task_id, str) and task_id else None
|
|
604
|
+
if not task_id:
|
|
605
|
+
# Old backend deliver frame — no task_id stamped. Degrade
|
|
606
|
+
# silently to pre-M7 behaviour.
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
already = await self._injection_state.is_injected(
|
|
611
|
+
agent_id, task_id
|
|
612
|
+
)
|
|
613
|
+
except Exception: # noqa: BLE001 — fail-open: try injecting
|
|
614
|
+
logger.exception(
|
|
615
|
+
"claude_runner: injection_state.is_injected failed "
|
|
616
|
+
"agent=%s task=%s — proceeding with fetch", agent_id, task_id,
|
|
617
|
+
)
|
|
618
|
+
already = False
|
|
619
|
+
if already:
|
|
620
|
+
logger.info(
|
|
621
|
+
"claude_runner: task context already injected for "
|
|
622
|
+
"agent=%s task=%s — skipping re-fetch (W3-04)",
|
|
623
|
+
agent_id, task_id,
|
|
624
|
+
)
|
|
625
|
+
return
|
|
626
|
+
|
|
627
|
+
# The injection target is the claude subprocess cwd — claude
|
|
628
|
+
# reads project-level CLAUDE.md / .claude / .memory relative to
|
|
629
|
+
# its cwd, exactly as a cloud pod reads them from /workspace.
|
|
630
|
+
from .context_fetch import fetch_and_write_task_context
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
injected = await fetch_and_write_task_context(
|
|
634
|
+
self._http,
|
|
635
|
+
workspace=ws.cwd,
|
|
636
|
+
agent_id=agent_id,
|
|
637
|
+
task_id=task_id,
|
|
638
|
+
)
|
|
639
|
+
except Exception: # noqa: BLE001 — fail-safe (context_fetch
|
|
640
|
+
# already guards, this is belt-and-braces)
|
|
641
|
+
logger.exception(
|
|
642
|
+
"claude_runner: task context injection raised "
|
|
643
|
+
"agent=%s task=%s — wake proceeds without injection",
|
|
644
|
+
agent_id, task_id,
|
|
645
|
+
)
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
if injected:
|
|
649
|
+
# Mark only after a successful write so a transient fetch
|
|
650
|
+
# failure can be retried on the next delegation.
|
|
651
|
+
try:
|
|
652
|
+
await self._injection_state.mark_injected(agent_id, task_id)
|
|
653
|
+
except Exception: # noqa: BLE001
|
|
654
|
+
logger.exception(
|
|
655
|
+
"claude_runner: injection_state.mark_injected failed "
|
|
656
|
+
"agent=%s task=%s — may re-inject next delegation "
|
|
657
|
+
"(idempotent)", agent_id, task_id,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
async def _maybe_report_delegation(
|
|
661
|
+
self,
|
|
662
|
+
payload: dict,
|
|
663
|
+
*,
|
|
664
|
+
timeout_hit: bool,
|
|
665
|
+
exit_code: int,
|
|
666
|
+
result_usage: dict | None,
|
|
667
|
+
final_result_text: str | None,
|
|
668
|
+
) -> None:
|
|
669
|
+
"""Report a delegation's outcome + token usage to the backend.
|
|
670
|
+
|
|
671
|
+
No-op when the runner has no ``http`` wired or the deliver frame
|
|
672
|
+
carries no ``delegation_id`` (the work was not a delegation —
|
|
673
|
+
test-chat / direct task).
|
|
674
|
+
|
|
675
|
+
Status mapping: a clean exit (``rc==0`` and no timeout) →
|
|
676
|
+
``completed``; anything else → ``failed``. The whole-turn
|
|
677
|
+
``usage`` comes from the Claude CLI ``result`` event.
|
|
678
|
+
|
|
679
|
+
NEVER raises (acceptance W3-06).
|
|
680
|
+
"""
|
|
681
|
+
if self._http is None:
|
|
682
|
+
return
|
|
683
|
+
delegation_id = payload.get("delegation_id")
|
|
684
|
+
delegation_id = (
|
|
685
|
+
delegation_id
|
|
686
|
+
if isinstance(delegation_id, str) and delegation_id
|
|
687
|
+
else None
|
|
688
|
+
)
|
|
689
|
+
if not delegation_id:
|
|
690
|
+
return
|
|
691
|
+
|
|
692
|
+
status = "completed" if (exit_code == 0 and not timeout_hit) else "failed"
|
|
693
|
+
from .delegation_report import report_delegation_status
|
|
694
|
+
|
|
695
|
+
try:
|
|
696
|
+
await report_delegation_status(
|
|
697
|
+
self._http,
|
|
698
|
+
delegation_id=delegation_id,
|
|
699
|
+
status=status,
|
|
700
|
+
usage=result_usage,
|
|
701
|
+
result=final_result_text,
|
|
702
|
+
outbox=self._delegation_outbox, # type: ignore[arg-type]
|
|
703
|
+
)
|
|
704
|
+
except Exception: # noqa: BLE001 — fail-safe (report_delegation_
|
|
705
|
+
# status already guards, belt-and-braces)
|
|
706
|
+
logger.exception(
|
|
707
|
+
"claude_runner: delegation status report raised "
|
|
708
|
+
"delegation=%s — wake outcome unaffected", delegation_id,
|
|
709
|
+
)
|
|
710
|
+
|
|
530
711
|
async def _spawn_attempt(
|
|
531
712
|
self,
|
|
532
713
|
ws: AgentWorkspaceLike,
|
|
@@ -551,11 +732,13 @@ The daemon discards stdout — only the mcp tool call reaches the user.
|
|
|
551
732
|
append_system_prompt = (
|
|
552
733
|
raw_append if isinstance(raw_append, str) and raw_append.strip() else None
|
|
553
734
|
)
|
|
735
|
+
runtime_engine = self._runtime_engine_for(ws, payload)
|
|
554
736
|
cmd = self._build_cmd(
|
|
555
737
|
ws,
|
|
556
738
|
agent_id,
|
|
557
739
|
with_resume=with_resume,
|
|
558
740
|
append_system_prompt=append_system_prompt,
|
|
741
|
+
runtime_engine=runtime_engine,
|
|
559
742
|
)
|
|
560
743
|
|
|
561
744
|
# Task #118 (2026-05-12) — spawn cwd is now the agent-specific
|
|
@@ -568,8 +751,9 @@ The daemon discards stdout — only the mcp tool call reaches the user.
|
|
|
568
751
|
|
|
569
752
|
# 1i Bug C — Phase 1 demo visibility.
|
|
570
753
|
logger.info(
|
|
571
|
-
"claude_runner: spawning
|
|
754
|
+
"claude_runner: spawning runtime=%s agent=%s msg=%s prior_session=%s "
|
|
572
755
|
"with_resume=%s cwd=%s cmd=%s",
|
|
756
|
+
runtime_engine,
|
|
573
757
|
agent_id,
|
|
574
758
|
payload.get("message_id"),
|
|
575
759
|
ws.last_session_id(),
|
|
@@ -630,8 +814,16 @@ The daemon discards stdout — only the mcp tool call reaches the user.
|
|
|
630
814
|
# chunk. session_id is ``test_chat_{host}_{agent}`` for test-
|
|
631
815
|
# chat deliveries (the new per-agent shape from 1k73).
|
|
632
816
|
deliver_session_id = payload.get("session_id")
|
|
633
|
-
|
|
817
|
+
(
|
|
818
|
+
captured_session,
|
|
819
|
+
tool_uses,
|
|
820
|
+
final_text,
|
|
821
|
+
result_usage,
|
|
822
|
+
) = await self._consume_stdout(
|
|
634
823
|
proc,
|
|
824
|
+
parse_mode=(
|
|
825
|
+
"codex_json" if runtime_engine == "codex" else "stream_json"
|
|
826
|
+
),
|
|
635
827
|
partial_session_id=(
|
|
636
828
|
deliver_session_id
|
|
637
829
|
if isinstance(deliver_session_id, str)
|
|
@@ -655,6 +847,7 @@ The daemon discards stdout — only the mcp tool call reaches the user.
|
|
|
655
847
|
timeout_hit=timeout_hit,
|
|
656
848
|
tool_uses=tool_uses,
|
|
657
849
|
final_result_text=final_text,
|
|
850
|
+
result_usage=result_usage,
|
|
658
851
|
)
|
|
659
852
|
|
|
660
853
|
# ------------------------------------------------------------------
|
|
@@ -782,6 +975,57 @@ Available cloudagent tools:
|
|
|
782
975
|
return ""
|
|
783
976
|
return "[meta]\n" + "\n".join(lines) + "\n[/meta]\n\n"
|
|
784
977
|
|
|
978
|
+
def _runtime_engine_for(self, ws: AgentWorkspaceLike, payload: dict) -> str:
|
|
979
|
+
"""Resolve the engine for this wake.
|
|
980
|
+
|
|
981
|
+
Backend sends ``runtime_engine`` on deliver frames after 2026-05-25
|
|
982
|
+
and also persists it in ``identity.json`` via ready/assign. Reading
|
|
983
|
+
both keeps old queued frames working after a daemon restart.
|
|
984
|
+
"""
|
|
985
|
+
raw = payload.get("runtime_engine")
|
|
986
|
+
if not isinstance(raw, str) or not raw.strip():
|
|
987
|
+
try:
|
|
988
|
+
identity = json.loads(
|
|
989
|
+
(ws.path / ".meta" / "identity.json").read_text(
|
|
990
|
+
encoding="utf-8"
|
|
991
|
+
)
|
|
992
|
+
)
|
|
993
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
994
|
+
identity = {}
|
|
995
|
+
raw = identity.get("runtime_engine")
|
|
996
|
+
engine = raw.strip() if isinstance(raw, str) else ""
|
|
997
|
+
return "codex" if engine == "codex" else "claude_code"
|
|
998
|
+
|
|
999
|
+
def _fallback_reply_args(self, payload: dict, body: str) -> dict:
|
|
1000
|
+
"""Build daemon fallback ``send_message`` args.
|
|
1001
|
+
|
|
1002
|
+
Claude normally calls the MCP tool itself; Codex can also use the
|
|
1003
|
+
configured MCP server, but the fallback is what keeps a plain final
|
|
1004
|
+
answer visible if a runtime prints text and exits. Route replies to
|
|
1005
|
+
the sender role when this was agent-to-agent work, and to the human
|
|
1006
|
+
surface for direct user/task wakes.
|
|
1007
|
+
"""
|
|
1008
|
+
if payload.get("dispatch_source") == "user_direct":
|
|
1009
|
+
target = "user"
|
|
1010
|
+
else:
|
|
1011
|
+
target = (
|
|
1012
|
+
payload.get("sender_role")
|
|
1013
|
+
or payload.get("from")
|
|
1014
|
+
or payload.get("sender")
|
|
1015
|
+
or "user"
|
|
1016
|
+
)
|
|
1017
|
+
if not isinstance(target, str) or not target.strip():
|
|
1018
|
+
target = "user"
|
|
1019
|
+
args: dict = {
|
|
1020
|
+
"target": target.strip(),
|
|
1021
|
+
"body": body,
|
|
1022
|
+
"kind": "reply",
|
|
1023
|
+
}
|
|
1024
|
+
parent = payload.get("parent_msg_id") or payload.get("message_id")
|
|
1025
|
+
if parent not in (None, ""):
|
|
1026
|
+
args["parent_msg_id"] = parent
|
|
1027
|
+
return args
|
|
1028
|
+
|
|
785
1029
|
def _build_cmd(
|
|
786
1030
|
self,
|
|
787
1031
|
ws: AgentWorkspaceLike,
|
|
@@ -789,6 +1033,7 @@ Available cloudagent tools:
|
|
|
789
1033
|
*,
|
|
790
1034
|
with_resume: bool = True,
|
|
791
1035
|
append_system_prompt: str | None = None,
|
|
1036
|
+
runtime_engine: str = "claude_code",
|
|
792
1037
|
) -> list[str]:
|
|
793
1038
|
"""Build the ``claude`` argv list for one wake.
|
|
794
1039
|
|
|
@@ -803,8 +1048,45 @@ Available cloudagent tools:
|
|
|
803
1048
|
contract footer with a blank-line separator so it shows up at the
|
|
804
1049
|
very tail of ``--append-system-prompt``.
|
|
805
1050
|
"""
|
|
806
|
-
cmd: list[str] = [self.claude_binary]
|
|
807
1051
|
last_session = ws.last_session_id() if with_resume else None
|
|
1052
|
+
if runtime_engine == "codex":
|
|
1053
|
+
binary = self.runtime_binaries.get("codex") or "codex"
|
|
1054
|
+
bridge_args = [
|
|
1055
|
+
"mcp-bridge",
|
|
1056
|
+
"--agent-id",
|
|
1057
|
+
agent_id,
|
|
1058
|
+
"--token-file",
|
|
1059
|
+
str(ws.path / ".meta" / "claude-token.txt"),
|
|
1060
|
+
]
|
|
1061
|
+
cmd: list[str] = [
|
|
1062
|
+
binary,
|
|
1063
|
+
"-c",
|
|
1064
|
+
"mcp_servers.cloudagent.command="
|
|
1065
|
+
+ json.dumps("cloudagent-daemon"),
|
|
1066
|
+
"-c",
|
|
1067
|
+
"mcp_servers.cloudagent.args=" + json.dumps(bridge_args),
|
|
1068
|
+
]
|
|
1069
|
+
if last_session:
|
|
1070
|
+
cmd += [
|
|
1071
|
+
"exec",
|
|
1072
|
+
"resume",
|
|
1073
|
+
"--json",
|
|
1074
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
1075
|
+
"--skip-git-repo-check",
|
|
1076
|
+
last_session,
|
|
1077
|
+
"-",
|
|
1078
|
+
]
|
|
1079
|
+
else:
|
|
1080
|
+
cmd += [
|
|
1081
|
+
"exec",
|
|
1082
|
+
"--json",
|
|
1083
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
1084
|
+
"--skip-git-repo-check",
|
|
1085
|
+
"-",
|
|
1086
|
+
]
|
|
1087
|
+
return cmd
|
|
1088
|
+
|
|
1089
|
+
cmd = [self.runtime_binaries.get("claude_code") or self.claude_binary]
|
|
808
1090
|
if last_session:
|
|
809
1091
|
cmd += ["--resume", last_session]
|
|
810
1092
|
# 1i Bug F — inject operational contract via --append-system-prompt.
|
|
@@ -847,14 +1129,104 @@ Available cloudagent tools:
|
|
|
847
1129
|
cmd += ["--permission-mode", self.claude_permission_mode]
|
|
848
1130
|
return cmd
|
|
849
1131
|
|
|
1132
|
+
async def _consume_codex_stdout(
|
|
1133
|
+
self,
|
|
1134
|
+
proc: asyncio.subprocess.Process,
|
|
1135
|
+
*,
|
|
1136
|
+
partial_session_id: str | None = None,
|
|
1137
|
+
partial_agent_id: str | None = None,
|
|
1138
|
+
) -> tuple[str | None, list[str], str | None, dict | None]:
|
|
1139
|
+
"""Consume ``codex exec --json`` NDJSON.
|
|
1140
|
+
|
|
1141
|
+
Codex emits ``thread.started`` with ``thread_id`` and one or more
|
|
1142
|
+
``item.completed`` events for assistant text. Tool-use event names
|
|
1143
|
+
are intentionally ignored here; if Codex prints a final answer
|
|
1144
|
+
without calling MCP, the caller's fallback posts the text through
|
|
1145
|
+
``send_message`` so the conversation still advances.
|
|
1146
|
+
"""
|
|
1147
|
+
captured: str | None = None
|
|
1148
|
+
final_chunks: list[str] = []
|
|
1149
|
+
result_usage: dict | None = None
|
|
1150
|
+
publish_enabled = bool(
|
|
1151
|
+
self._partial_publisher
|
|
1152
|
+
and partial_session_id
|
|
1153
|
+
and partial_agent_id
|
|
1154
|
+
)
|
|
1155
|
+
if proc.stdout is None:
|
|
1156
|
+
return None, [], None, None
|
|
1157
|
+
|
|
1158
|
+
async for line_b in proc.stdout:
|
|
1159
|
+
line = line_b.decode("utf-8", errors="replace").strip()
|
|
1160
|
+
if not line:
|
|
1161
|
+
continue
|
|
1162
|
+
try:
|
|
1163
|
+
evt = json.loads(line)
|
|
1164
|
+
except json.JSONDecodeError:
|
|
1165
|
+
logger.debug("CodexRunner: non-JSON stdout line: %r", line[:120])
|
|
1166
|
+
continue
|
|
1167
|
+
if not isinstance(evt, dict):
|
|
1168
|
+
continue
|
|
1169
|
+
|
|
1170
|
+
evt_type = evt.get("type")
|
|
1171
|
+
if evt_type == "thread.started" and captured is None:
|
|
1172
|
+
sid = evt.get("thread_id")
|
|
1173
|
+
if isinstance(sid, str) and sid:
|
|
1174
|
+
captured = sid
|
|
1175
|
+
logger.info("CodexRunner: captured thread_id=%s", sid)
|
|
1176
|
+
continue
|
|
1177
|
+
|
|
1178
|
+
if evt_type == "item.completed":
|
|
1179
|
+
item = evt.get("item")
|
|
1180
|
+
if isinstance(item, dict) and item.get("type") == "agent_message":
|
|
1181
|
+
text = item.get("text")
|
|
1182
|
+
if isinstance(text, str) and text:
|
|
1183
|
+
final_chunks.append(text)
|
|
1184
|
+
if publish_enabled:
|
|
1185
|
+
assert self._partial_publisher is not None
|
|
1186
|
+
assert partial_session_id is not None
|
|
1187
|
+
assert partial_agent_id is not None
|
|
1188
|
+
try:
|
|
1189
|
+
await self._partial_publisher(
|
|
1190
|
+
partial_session_id,
|
|
1191
|
+
partial_agent_id,
|
|
1192
|
+
"\n\n".join(final_chunks),
|
|
1193
|
+
)
|
|
1194
|
+
except Exception as exc: # noqa: BLE001
|
|
1195
|
+
logger.info(
|
|
1196
|
+
"partial_publisher failed (non-fatal): %r",
|
|
1197
|
+
exc,
|
|
1198
|
+
)
|
|
1199
|
+
continue
|
|
1200
|
+
|
|
1201
|
+
if evt_type == "turn.completed":
|
|
1202
|
+
usage = evt.get("usage") or evt.get("token_usage")
|
|
1203
|
+
if isinstance(usage, dict):
|
|
1204
|
+
result_usage = usage
|
|
1205
|
+
continue
|
|
1206
|
+
|
|
1207
|
+
if evt_type in {"turn.failed", "error"}:
|
|
1208
|
+
err = evt.get("error")
|
|
1209
|
+
msg = None
|
|
1210
|
+
if isinstance(err, dict):
|
|
1211
|
+
raw = err.get("message")
|
|
1212
|
+
msg = raw if isinstance(raw, str) else None
|
|
1213
|
+
if msg is None:
|
|
1214
|
+
raw = evt.get("message")
|
|
1215
|
+
msg = raw if isinstance(raw, str) else None
|
|
1216
|
+
logger.warning("CodexRunner: stdout error event=%s msg=%r", evt_type, msg)
|
|
1217
|
+
|
|
1218
|
+
final_text = "\n\n".join(final_chunks) if final_chunks else None
|
|
1219
|
+
return captured, [], final_text, result_usage
|
|
1220
|
+
|
|
850
1221
|
async def _consume_stdout(
|
|
851
1222
|
self,
|
|
852
1223
|
proc: asyncio.subprocess.Process,
|
|
853
1224
|
*,
|
|
1225
|
+
parse_mode: str = "stream_json",
|
|
854
1226
|
partial_session_id: str | None = None,
|
|
855
1227
|
partial_agent_id: str | None = None,
|
|
856
|
-
) -> tuple[str | None, list[str], str | None]:
|
|
857
|
-
"""Stream NDJSON from stdout; return ``(session_id, tool_uses, result_text)``.
|
|
1228
|
+
) -> tuple[str | None, list[str], str | None, dict | None]:
|
|
1229
|
+
"""Stream NDJSON from stdout; return ``(session_id, tool_uses, result_text, result_usage)``.
|
|
858
1230
|
|
|
859
1231
|
Accepts BOTH session-init event shapes for forward/backward
|
|
860
1232
|
compatibility:
|
|
@@ -876,12 +1248,32 @@ Available cloudagent tools:
|
|
|
876
1248
|
name the same field differently). Used as the body of the
|
|
877
1249
|
fallback ``send_message`` POST.
|
|
878
1250
|
|
|
1251
|
+
M7 W3 — additionally extracts ``result_usage``: the ``usage``
|
|
1252
|
+
object the Claude CLI carries on the terminating
|
|
1253
|
+
``type == "result"`` event. This is the **whole-turn** token
|
|
1254
|
+
total (the CLI already aggregates every assistant message's
|
|
1255
|
+
usage into it), so the daemon reads it straight off the result
|
|
1256
|
+
event — it must NOT sum per-``message.usage`` blobs, that would
|
|
1257
|
+
double-count against the result aggregate. Normalised to the
|
|
1258
|
+
backend ``_INITIAL_USAGE`` shape via
|
|
1259
|
+
``delegation_report.normalize_usage`` and used as the ``usage``
|
|
1260
|
+
field of the ``POST /v1/daemon/delegation-status`` report.
|
|
1261
|
+
|
|
879
1262
|
Also surfaces ``type=result`` and ``type=tool_use`` events at INFO
|
|
880
1263
|
for Phase 1 demo visibility (1i Bug C).
|
|
881
1264
|
"""
|
|
1265
|
+
if parse_mode == "codex_json":
|
|
1266
|
+
return await self._consume_codex_stdout(
|
|
1267
|
+
proc,
|
|
1268
|
+
partial_session_id=partial_session_id,
|
|
1269
|
+
partial_agent_id=partial_agent_id,
|
|
1270
|
+
)
|
|
1271
|
+
|
|
882
1272
|
captured: str | None = None
|
|
883
1273
|
tool_uses: list[str] = []
|
|
884
1274
|
final_text: str | None = None
|
|
1275
|
+
# M7 W3 — whole-turn usage from the terminating ``result`` event.
|
|
1276
|
+
result_usage: dict | None = None
|
|
885
1277
|
# 1k74 — running buffer for token-level streaming. claude emits
|
|
886
1278
|
# one ``stream_event`` per content_block_delta (~80 chars at a
|
|
887
1279
|
# time); we concatenate them and forward the running total to
|
|
@@ -898,7 +1290,7 @@ Available cloudagent tools:
|
|
|
898
1290
|
_cache_read_tokens: int = 0
|
|
899
1291
|
_cache_creation_tokens: int = 0
|
|
900
1292
|
if proc.stdout is None:
|
|
901
|
-
return None, tool_uses, final_text
|
|
1293
|
+
return None, tool_uses, final_text, result_usage
|
|
902
1294
|
|
|
903
1295
|
async for line_b in proc.stdout:
|
|
904
1296
|
line = line_b.decode("utf-8", errors="replace").strip()
|
|
@@ -1021,15 +1413,26 @@ Available cloudagent tools:
|
|
|
1021
1413
|
result_text = alt
|
|
1022
1414
|
if isinstance(result_text, str) and result_text:
|
|
1023
1415
|
final_text = result_text
|
|
1416
|
+
# M7 W3 — capture the whole-turn ``usage`` aggregate the
|
|
1417
|
+
# CLI stamps on the terminating result event. Take it
|
|
1418
|
+
# verbatim here; normalisation to ``_INITIAL_USAGE``
|
|
1419
|
+
# shape happens in the caller via
|
|
1420
|
+
# ``delegation_report.normalize_usage``. Do NOT add
|
|
1421
|
+
# per-message usage — the result event already sums it.
|
|
1422
|
+
evt_usage = evt.get("usage")
|
|
1423
|
+
if isinstance(evt_usage, dict):
|
|
1424
|
+
result_usage = evt_usage
|
|
1024
1425
|
summary = (
|
|
1025
1426
|
result_text[:120] + "..."
|
|
1026
1427
|
if isinstance(result_text, str) and len(result_text) > 120
|
|
1027
1428
|
else result_text
|
|
1028
1429
|
)
|
|
1029
1430
|
logger.info(
|
|
1030
|
-
"ClaudeRunner: stdout result subtype=%s text=%r"
|
|
1431
|
+
"ClaudeRunner: stdout result subtype=%s text=%r "
|
|
1432
|
+
"usage_present=%s",
|
|
1031
1433
|
evt_subtype,
|
|
1032
1434
|
summary,
|
|
1435
|
+
result_usage is not None,
|
|
1033
1436
|
)
|
|
1034
1437
|
elif evt_type == "tool_use":
|
|
1035
1438
|
# Standalone tool_use event (older / alternate shape).
|
|
@@ -1067,7 +1470,7 @@ Available cloudagent tools:
|
|
|
1067
1470
|
"claude_runner: cache metrics publish failed", exc_info=True
|
|
1068
1471
|
)
|
|
1069
1472
|
|
|
1070
|
-
return captured, tool_uses, final_text
|
|
1473
|
+
return captured, tool_uses, final_text, result_usage
|
|
1071
1474
|
|
|
1072
1475
|
async def _wait_with_timeout(
|
|
1073
1476
|
self, proc: asyncio.subprocess.Process
|
|
@@ -642,15 +642,16 @@ def install_service_cmd(
|
|
|
642
642
|
),
|
|
643
643
|
profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
|
|
644
644
|
permission_mode: str = typer.Option(
|
|
645
|
-
"
|
|
645
|
+
"bypassPermissions",
|
|
646
646
|
"--permission-mode",
|
|
647
647
|
help=(
|
|
648
648
|
"Claude CLI permission mode for daemon-spawned staff agents. "
|
|
649
|
-
"'
|
|
649
|
+
"'bypassPermissions' — every tool runs unattended (default; "
|
|
650
|
+
"needed for staff that run git/build via Bash). 'default' — "
|
|
651
|
+
"only the cloudagent MCP toolset runs unattended "
|
|
650
652
|
"(staff cannot touch the filesystem). 'acceptEdits' — file "
|
|
651
653
|
"edits auto-accepted within the agent's working directory "
|
|
652
|
-
"(Bash still gates).
|
|
653
|
-
"unattended (needed for staff that run git/build via Bash). "
|
|
654
|
+
"(Bash still gates). "
|
|
654
655
|
"Baked into the service unit as CLOUDAGENT_DAEMON_PERMISSION_MODE."
|
|
655
656
|
),
|
|
656
657
|
),
|