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.
Files changed (84) hide show
  1. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/.gitignore +3 -1
  2. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/PKG-INFO +1 -1
  3. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/pyproject.toml +1 -1
  4. cloudagent_daemon-0.3.0/src/cloudagent_daemon/__init__.py +1 -0
  5. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/claude_runner.py +421 -18
  6. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/cli.py +5 -4
  7. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/config.py +3 -3
  8. cloudagent_daemon-0.3.0/src/cloudagent_daemon/context_fetch.py +336 -0
  9. cloudagent_daemon-0.3.0/src/cloudagent_daemon/delegation_report.py +338 -0
  10. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/frame_dispatch.py +19 -1
  11. cloudagent_daemon-0.3.0/src/cloudagent_daemon/http_outbound.py +185 -0
  12. cloudagent_daemon-0.3.0/src/cloudagent_daemon/injection_state.py +144 -0
  13. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/main_loop.py +87 -0
  14. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/paths.py +28 -0
  15. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/protocol.py +3 -0
  16. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/service.py +8 -10
  17. cloudagent_daemon-0.3.0/tests/integration/__init__.py +0 -0
  18. cloudagent_daemon-0.3.0/tests/integration/_fake_sse_server.py +360 -0
  19. cloudagent_daemon-0.3.0/tests/integration/conftest.py +248 -0
  20. cloudagent_daemon-0.3.0/tests/integration/test_e2e_deliver_to_spawn.py +290 -0
  21. cloudagent_daemon-0.3.0/tests/integration/test_e2e_sse.py +266 -0
  22. cloudagent_daemon-0.3.0/tests/integration/test_full_e2e.py +336 -0
  23. cloudagent_daemon-0.3.0/tests/integration/test_mcp_bridge_round_trip.py +477 -0
  24. cloudagent_daemon-0.3.0/tests/integration/test_one_shot_cli.py +321 -0
  25. cloudagent_daemon-0.3.0/tests/integration/test_spawn_mcp_bridge_e2e.py +309 -0
  26. cloudagent_daemon-0.3.0/tests/integration/test_sse_handshake.py +204 -0
  27. cloudagent_daemon-0.3.0/tests/integration/test_sse_watchdog.py +120 -0
  28. cloudagent_daemon-0.3.0/tests/integration/test_ws_handshake.py +156 -0
  29. cloudagent_daemon-0.3.0/tests/integration/test_ws_reconnect.py +300 -0
  30. cloudagent_daemon-0.3.0/tests/unit/conftest.py +25 -0
  31. cloudagent_daemon-0.3.0/tests/unit/test_claude_runner.py +1939 -0
  32. cloudagent_daemon-0.3.0/tests/unit/test_claude_runner_routing_contract.py +68 -0
  33. cloudagent_daemon-0.3.0/tests/unit/test_claude_runner_w3.py +473 -0
  34. cloudagent_daemon-0.3.0/tests/unit/test_cli_multi_profile.py +199 -0
  35. cloudagent_daemon-0.3.0/tests/unit/test_cli_smoke.py +169 -0
  36. cloudagent_daemon-0.3.0/tests/unit/test_config.py +323 -0
  37. cloudagent_daemon-0.3.0/tests/unit/test_context_fetch.py +296 -0
  38. cloudagent_daemon-0.3.0/tests/unit/test_delegation_report.py +282 -0
  39. cloudagent_daemon-0.3.0/tests/unit/test_doctor.py +300 -0
  40. cloudagent_daemon-0.3.0/tests/unit/test_frame_dispatch.py +484 -0
  41. cloudagent_daemon-0.3.0/tests/unit/test_http_outbound.py +367 -0
  42. cloudagent_daemon-0.3.0/tests/unit/test_injection_state.py +93 -0
  43. cloudagent_daemon-0.3.0/tests/unit/test_jsonl_logging.py +110 -0
  44. cloudagent_daemon-0.3.0/tests/unit/test_login.py +165 -0
  45. cloudagent_daemon-0.3.0/tests/unit/test_main_loop_assign.py +316 -0
  46. cloudagent_daemon-0.3.0/tests/unit/test_mcp_bridge_send_message.py +133 -0
  47. cloudagent_daemon-0.3.0/tests/unit/test_metrics.py +143 -0
  48. cloudagent_daemon-0.3.0/tests/unit/test_metrics_server.py +118 -0
  49. cloudagent_daemon-0.3.0/tests/unit/test_multi_tool_wake.py +382 -0
  50. cloudagent_daemon-0.3.0/tests/unit/test_outbound_queue.py +176 -0
  51. cloudagent_daemon-0.3.0/tests/unit/test_outbox_stuck_watchdog.py +393 -0
  52. cloudagent_daemon-0.3.0/tests/unit/test_paths.py +267 -0
  53. cloudagent_daemon-0.3.0/tests/unit/test_protocol.py +166 -0
  54. cloudagent_daemon-0.3.0/tests/unit/test_runtime_detect.py +236 -0
  55. cloudagent_daemon-0.3.0/tests/unit/test_service.py +308 -0
  56. cloudagent_daemon-0.3.0/tests/unit/test_service_multi_profile.py +457 -0
  57. cloudagent_daemon-0.3.0/tests/unit/test_spawn_outbox.py +295 -0
  58. cloudagent_daemon-0.3.0/tests/unit/test_spawn_queue.py +300 -0
  59. cloudagent_daemon-0.3.0/tests/unit/test_sse_active_watchdog.py +175 -0
  60. cloudagent_daemon-0.3.0/tests/unit/test_status_stop.py +170 -0
  61. cloudagent_daemon-0.3.0/tests/unit/test_workspace.py +580 -0
  62. cloudagent_daemon-0.3.0/tests/unit/test_workspace_root_task113.py +127 -0
  63. cloudagent_daemon-0.3.0/tests/unit/test_ws_client_insecure.py +49 -0
  64. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/uv.lock +1 -1
  65. cloudagent_daemon-0.2.6/src/cloudagent_daemon/__init__.py +0 -1
  66. cloudagent_daemon-0.2.6/src/cloudagent_daemon/http_outbound.py +0 -80
  67. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/LICENSE +0 -0
  68. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/README.md +0 -0
  69. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/__main__.py +0 -0
  70. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/doctor.py +0 -0
  71. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  72. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/login.py +0 -0
  73. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/mcp_bridge.py +0 -0
  74. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/metrics.py +0 -0
  75. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/metrics_server.py +0 -0
  76. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/outbound_queue.py +0 -0
  77. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/runtime_detect.py +0 -0
  78. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  79. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/spawn_queue.py +0 -0
  80. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/sse_client.py +0 -0
  81. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/transport.py +0 -0
  82. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/unix_socket.py +0 -0
  83. {cloudagent_daemon-0.2.6 → cloudagent_daemon-0.3.0}/src/cloudagent_daemon/workspace.py +0 -0
  84. {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.2.6
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.2.6"
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 = "default",
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
- # Claude CLI ``--permission-mode``. "default" → flag omitted
207
- # (legacy behaviour: only the cloudagent MCP toolset is
208
- # whitelisted via --allowedTools, Bash/Write/Edit gate). See
209
- # DaemonConfig.claude_permission_mode for the per-mode contract.
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
- "target": "user",
455
- "body": attempt.final_result_text,
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 claude agent=%s msg=%s prior_session=%s "
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
- captured_session, tool_uses, final_text = await self._consume_stdout(
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
- "default",
645
+ "bypassPermissions",
646
646
  "--permission-mode",
647
647
  help=(
648
648
  "Claude CLI permission mode for daemon-spawned staff agents. "
649
- "'default' — only the cloudagent MCP toolset runs unattended "
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). 'bypassPermissions' — every tool runs "
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
  ),