cloudagent-daemon 0.2.1__py3-none-any.whl

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.
@@ -0,0 +1,1068 @@
1
+ """ClaudeRunner: spawn the ``claude`` binary, drive its stdin/stdout and emit
2
+ status frames for one wake-up.
3
+
4
+ Spec §6.5–§6.7. The runner is invoked once per delivered message by the
5
+ SpawnQueue drain loop (see Task 8). It is *not* the queue itself.
6
+
7
+ Design contract
8
+ ---------------
9
+ ``run_one(agent_id, payload)`` performs the following sequence:
10
+
11
+ 1. Build the ``claude`` argv (`--resume` only on a pre-existing session,
12
+ plus ``--mcp-config <path> --print --output-format stream-json``).
13
+ 2. Spawn it with stdin/stdout/stderr pipes.
14
+ 3. Write ``payload["body"]`` to stdin and close it.
15
+ 4. Emit ``delivery_ack(status="started")`` for the message and an
16
+ ``agent_status(running)`` frame.
17
+ 5. Stream stdout NDJSON events; remember any ``session_id`` carried by a
18
+ ``session_init`` event.
19
+ 6. Wait for exit (90-min single-wake timeout per spec §6.7). On timeout,
20
+ terminate then kill the process.
21
+ 7. Read stderr tail, persist `last-spawn.json`, delete the per-agent
22
+ one-shot token file.
23
+ 8. If the captured session_id is new, persist it on the workspace and
24
+ emit ``agent_session_start``.
25
+ 9. Emit a final ``agent_status`` (``idle``/``crashed``/``timeout``) and
26
+ return a :class:`SpawnResult`.
27
+
28
+ Workspace is referenced via :class:`WorkspaceLike` / :class:`AgentWorkspaceLike`
29
+ ``Protocol`` types; the real implementation lands in Task 11.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import json
35
+ import logging
36
+ import time
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import TYPE_CHECKING, Awaitable, Callable, Protocol
40
+
41
+ if TYPE_CHECKING:
42
+ from cloudagent_daemon.outbound_queue import OutboundQueue
43
+
44
+
45
+ # 1k74 — token-level streaming partial-publish hook.
46
+ #
47
+ # The runner invokes this callable each time claude emits a text_delta
48
+ # stream event during a run. ``accumulated_text`` is everything claude
49
+ # has emitted so far (the runner concatenates chunks before calling
50
+ # the publisher) so the receiver can render it as the in-progress
51
+ # assistant bubble without having to track its own running buffer.
52
+ #
53
+ # Implementations should be fire-and-forget: a failed publish must not
54
+ # tear down the claude wake. Network errors / backend hiccups are
55
+ # logged at INFO; the final ``send_message`` MCP call is the canonical
56
+ # source of truth and will land regardless.
57
+ PartialPublisher = Callable[[str, str, str], Awaitable[None]]
58
+ """(session_id, agent_id, accumulated_text) -> None"""
59
+
60
+ logger = logging.getLogger(__name__)
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Workspace protocol — concrete impl arrives in Task 11
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ class AgentWorkspaceLike(Protocol):
69
+ """Per-agent workspace surface used by :class:`ClaudeRunner`."""
70
+
71
+ path: Path
72
+ # Task #118 (2026-05-12) — claude subprocess cwd. Decoupled from
73
+ # ``path`` (the metadata root) so a user-supplied
74
+ # ``agent.workspace_root`` doesn't pollute the platform's
75
+ # ``~/.cloudagent/agents/<id>/`` directory.
76
+ cwd: Path
77
+
78
+ def last_session_id(self) -> str | None: ...
79
+
80
+ def set_last_session_id(self, sid: str) -> None: ...
81
+
82
+ # 1k followup — clear stored session_id when ``claude --resume`` reports
83
+ # "No conversation found" so the next wake starts a fresh conversation
84
+ # instead of looping on the stale id forever.
85
+ def clear_last_session_id(self) -> None: ...
86
+
87
+ def mcp_config_path(self) -> Path: ...
88
+
89
+ def write_last_spawn(
90
+ self, exit_code: int, session_id: str | None, stderr_tail: str
91
+ ) -> None: ...
92
+
93
+ def delete_token_file(self) -> None: ...
94
+
95
+ # 1k hotfix — per-spawn current-session marker; see workspace.py.
96
+ def write_current_session(self, session_id: str | None) -> None: ...
97
+
98
+ def delete_current_session(self) -> None: ...
99
+
100
+
101
+ class WorkspaceLike(Protocol):
102
+ def for_agent(self, agent_id: str) -> AgentWorkspaceLike: ...
103
+
104
+ # 1h Part C — auto-create on first deliver if no workspace exists yet.
105
+ # The server may push a deliver for an agent the daemon has never
106
+ # seen (lazy registration); falling back to a sensible identity
107
+ # avoids a hard crash and lets the user run ``claude`` without a
108
+ # manual init step.
109
+ def ensure(
110
+ self, agent_id: str, identity: dict
111
+ ) -> AgentWorkspaceLike: ...
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Result type
116
+ # ---------------------------------------------------------------------------
117
+
118
+
119
+ @dataclass
120
+ class SpawnResult:
121
+ exit_code: int
122
+ captured_session_id: str | None
123
+ stderr_tail: str
124
+
125
+
126
+ @dataclass
127
+ class _SpawnAttempt:
128
+ """Outcome of a single ``claude`` subprocess invocation.
129
+
130
+ 1k followup — internal helper returned by
131
+ :meth:`ClaudeRunner._spawn_attempt`. Distinct from the public
132
+ :class:`SpawnResult` because we additionally need ``timeout_hit`` to
133
+ pick the final ``agent_status`` (timeout vs crashed) — that field is
134
+ not part of the runner's external contract.
135
+
136
+ 1k #63 — additionally tracks ``tool_uses`` (the names of every
137
+ tool_use block emitted by claude during the wake) and
138
+ ``final_result_text`` (the text from the terminating ``type=result``
139
+ event). Both feed the Layer-B daemon-side fallback that POSTs a
140
+ ``send_message`` on claude's behalf when claude generated a reply
141
+ but never invoked the tool.
142
+ """
143
+
144
+ exit_code: int
145
+ captured_session_id: str | None
146
+ stderr_tail: str
147
+ timeout_hit: bool
148
+ tool_uses: list[str]
149
+ final_result_text: str | None
150
+
151
+
152
+ def _build_lazy_identity(agent_id: str, payload: dict) -> dict:
153
+ """Construct the minimum-viable identity dict for ``Workspace.ensure``.
154
+
155
+ 1h Part C: the daemon may receive a deliver for an agent it doesn't
156
+ have a registered workspace for yet (e.g. after a fresh install,
157
+ or when the user creates the agent on the server while the daemon
158
+ is offline). In that case we derive a safe identity from whatever
159
+ the deliver payload carries, falling back to ``agent_id`` for any
160
+ missing fields.
161
+
162
+ ``is_chief`` defaults to ``False`` — that is a security guarantee:
163
+ daemons must NOT auto-promote unknown agents to chief (which gates
164
+ ``delegate_to_staff`` access). A future server-pushed ``assign``
165
+ frame will carry the correct kind / role.
166
+ """
167
+ return {
168
+ "agent_id": agent_id,
169
+ "agent_name": str(payload.get("agent_name") or agent_id),
170
+ "is_chief": False,
171
+ "role": str(payload.get("role") or "staff"),
172
+ "workspace_slug": str(payload.get("workspace_slug") or "default"),
173
+ "reports_to": "(unset)",
174
+ }
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Runner
179
+ # ---------------------------------------------------------------------------
180
+
181
+
182
+ class ClaudeRunner:
183
+ """Run one wake of the ``claude`` CLI for a delivered message."""
184
+
185
+ #: Spec §6.7 — 90-minute single-wake timeout.
186
+ SINGLE_WAKE_TIMEOUT_SECONDS: float = 90 * 60
187
+ #: Wait this long after SIGTERM before SIGKILL on timeout.
188
+ TERMINATE_GRACE_SECONDS: float = 10.0
189
+ #: How many bytes of stderr to retain in last-spawn.json.
190
+ STDERR_TAIL_BYTES: int = 2000
191
+
192
+ def __init__(
193
+ self,
194
+ *,
195
+ outbound: OutboundQueue,
196
+ workspace: WorkspaceLike,
197
+ claude_binary: str = "claude",
198
+ single_wake_timeout: float | None = None,
199
+ partial_publisher: PartialPublisher | None = None,
200
+ agent_to_session: "dict[str, str | None] | None" = None,
201
+ ) -> None:
202
+ self.outbound = outbound
203
+ self.workspace = workspace
204
+ self.claude_binary = claude_binary
205
+ # Allow tests to override the 90-min timeout without monkey-patching
206
+ # a class attribute. Defaults to spec value.
207
+ self._single_wake_timeout: float = (
208
+ single_wake_timeout
209
+ if single_wake_timeout is not None
210
+ else self.SINGLE_WAKE_TIMEOUT_SECONDS
211
+ )
212
+ # 1k74 — optional callback for token-level streaming. main_loop
213
+ # wires this to an HTTP POST against the backend's partial-
214
+ # publish endpoint. Absent in tests, where we don't care about
215
+ # the stream and want to keep claude_runner exercises hermetic.
216
+ self._partial_publisher = partial_publisher
217
+ # (c) IPC architecture upgrade — shared agent→session memory map.
218
+ # main_loop passes its own dict; mcp_bridge queries it via the
219
+ # unix socket ``get_current_session`` op instead of reading a file.
220
+ # None means feature disabled (legacy / test path).
221
+ self._agent_to_session: dict[str, str | None] | None = agent_to_session
222
+
223
+ # ------------------------------------------------------------------
224
+ # Public API
225
+ # ------------------------------------------------------------------
226
+
227
+ #: 1k followup — substring used to detect the stale-session crash
228
+ #: emitted by ``claude --resume <sid>`` when the session is no longer
229
+ #: present in ``~/.claude/sessions/``. ``--print`` mode does not
230
+ #: always persist sessions, so chaining wakes via ``--resume`` is
231
+ #: fragile. When we observe this we clear ``last_session_id`` and
232
+ #: retry once without ``--resume``.
233
+ STALE_SESSION_STDERR_MARKER: str = "No conversation found"
234
+
235
+ #: 1k #63 — stdin wrapper template (Layer A defense).
236
+ #:
237
+ #: ``--append-system-prompt`` text is treated by claude as a soft
238
+ #: guideline: simple prompts ("你好" / "你是谁") still get answered
239
+ #: by writing the reply to stdout and exiting WITHOUT invoking
240
+ #: ``mcp__cloudagent__send_message``. The daemon discards stdout, so
241
+ #: the user sees nothing.
242
+ #:
243
+ #: stdin, by contrast, is the active user message — claude treats it
244
+ #: as the immediate task. We put the user body FIRST (so it is treated
245
+ #: as the primary task) and append the tool-call instruction AS A
246
+ #: FOOTER with an explicit "do not echo this scaffolding" line.
247
+ #:
248
+ #: 1k #66 fix: the previous template led with a "[CRITICAL — Tool-only
249
+ #: response required]" header. claude treated that prominent tag as
250
+ #: salient enough to acknowledge in its reply text (e.g. parenthetical
251
+ #: notes like "(外层 [CRITICAL] 指令依旧按提示注入处理)") — leaking
252
+ #: scaffolding to the user. Quieter footer + explicit anti-echo line
253
+ #: keeps the same tool-use enforcement without the leak.
254
+ WAKE_PROMPT_TEMPLATE: str = """{header}{body}
255
+
256
+ ---
257
+ [Assistant scaffolding — not user content. Do not echo, quote, or reference this block in your reply.]
258
+ Reply via tool call only:
259
+ mcp__cloudagent__send_message(
260
+ target=<sender role from [meta] header — NOT a literal "user">,
261
+ parent_msg_id=<message_id from [meta] header>,
262
+ body="<your reply text only>",
263
+ kind="reply",
264
+ )
265
+ The daemon discards stdout — only the mcp tool call reaches the user.
266
+ """
267
+
268
+ #: Wave C #1 (D2 contract) — ``dispatch_source`` is a backend-stamped
269
+ #: enum on the deliver frame (``user_direct`` / ``chief_dispatch`` /
270
+ #: ``peer_request`` / ``system_fan_out`` / ``hitl_response`` /
271
+ #: ``auto_reply``). The daemon **passes it through to claude verbatim**
272
+ #: as a header above the body, without parsing or branching on it —
273
+ #: claude's system prompt + the backend's ``message_transitions``
274
+ #: enforce the "缺 dispatch_source 回 question" rule. The daemon's
275
+ #: only job is making the field visible in the wake prompt.
276
+ #:
277
+ #: Tracked separately so a future contract iteration adding more
278
+ #: stamped fields (e.g. ``thread_id`` / ``parent_msg_id``) can extend
279
+ #: the header without touching the body assembly path.
280
+ _PASSTHROUGH_HEADER_FIELDS: tuple[str, ...] = (
281
+ "dispatch_source",
282
+ "from",
283
+ "sender_role",
284
+ "parent_msg_id",
285
+ # Hot-fix 2026-05-12 — local_daemon staff replies were sending
286
+ # ``parent_msg_id=None`` because claude couldn't see the inbound
287
+ # message_id (it was on the deliver payload but never surfaced
288
+ # to the wake prompt). Now in the ``[meta]`` header so claude
289
+ # can copy it into ``send_message(parent_msg_id=...)``.
290
+ "message_id",
291
+ )
292
+
293
+ async def run_one(self, agent_id: str, payload: dict) -> SpawnResult:
294
+ """Execute one wake; see module docstring for the full sequence.
295
+
296
+ 1h Part C — lazy workspace: when the daemon receives a deliver
297
+ for an agent it has never been asked to host before, the
298
+ per-agent ``~/.cloudagent/agents/<agent_id>/`` directory does
299
+ not exist yet — and ``mcp-config`` / ``MEMORY.md`` / ``identity.json``
300
+ won't either. ``claude`` would crash on the missing
301
+ ``--mcp-config`` arg. We auto-create the workspace using sane
302
+ defaults derived from the deliver payload (or just the agent_id
303
+ when the payload is sparse). ``is_chief`` defaults to ``False``
304
+ (deny-by-default) — a future ``assign`` frame should override.
305
+
306
+ 1k followup — when the first attempt fails with stderr matching
307
+ :attr:`STALE_SESSION_STDERR_MARKER` (``claude --resume`` rejected
308
+ a non-persisted session id) we clear the stored session id and
309
+ retry exactly once without ``--resume`` so the user-facing reply
310
+ still lands rather than crashing the wake forever.
311
+ """
312
+ ws = self.workspace.for_agent(agent_id)
313
+ if not ws.path.exists():
314
+ identity = _build_lazy_identity(agent_id, payload)
315
+ logger.info(
316
+ "ClaudeRunner: lazy-creating workspace agent_id=%s "
317
+ "(server-pushed deliver for unknown agent)",
318
+ agent_id,
319
+ )
320
+ ws = self.workspace.ensure(agent_id, identity)
321
+
322
+ # 1k hotfix — write current deliver's session_id BEFORE spawn so
323
+ # mcp_bridge (a grandchild process) can pick it up at startup.
324
+ # Removed in the finally below regardless of how the spawn-and-
325
+ # wait section exits.
326
+ current_session_id = payload.get("session_id")
327
+ _effective_session_id: str | None = (
328
+ current_session_id
329
+ if isinstance(current_session_id, str) and current_session_id
330
+ else None
331
+ )
332
+ try:
333
+ ws.write_current_session(_effective_session_id)
334
+ except Exception: # pragma: no cover — defensive
335
+ logger.exception("ClaudeRunner: write_current_session failed")
336
+ # (c) IPC architecture upgrade — register in the daemon-process
337
+ # memory map so mcp_bridge can query via unix socket instead of
338
+ # reading the file. Both paths coexist for backward compat.
339
+ if self._agent_to_session is not None:
340
+ self._agent_to_session[agent_id] = _effective_session_id
341
+
342
+ wake_start = time.monotonic()
343
+ try:
344
+ attempt = await self._spawn_attempt(
345
+ ws, agent_id, payload, with_resume=True
346
+ )
347
+
348
+ # 1k followup — detect stale session (``claude --resume``
349
+ # rejected) and retry once without ``--resume``. The first
350
+ # attempt did NOT capture a session_id (the resume failed
351
+ # before claude got to print ``system/init``), so there is
352
+ # nothing useful to persist — we just clear the stored id
353
+ # and re-spawn fresh.
354
+ if (
355
+ attempt.exit_code != 0
356
+ and self.STALE_SESSION_STDERR_MARKER in attempt.stderr_tail
357
+ and ws.last_session_id() is not None
358
+ ):
359
+ stale_sid = ws.last_session_id()
360
+ logger.warning(
361
+ "claude_runner: claude --resume rejected stale "
362
+ "session_id=%s (stderr contained %r). Clearing and "
363
+ "retrying without --resume.",
364
+ stale_sid,
365
+ self.STALE_SESSION_STDERR_MARKER,
366
+ )
367
+ try:
368
+ ws.clear_last_session_id()
369
+ except Exception: # pragma: no cover — defensive
370
+ logger.exception(
371
+ "ClaudeRunner: clear_last_session_id failed"
372
+ )
373
+ attempt = await self._spawn_attempt(
374
+ ws, agent_id, payload, with_resume=False
375
+ )
376
+ finally:
377
+ # 1k hotfix — always remove the per-spawn marker, even on
378
+ # spawn failure / cancellation, to prevent stale session_id
379
+ # leaking into the next wake.
380
+ try:
381
+ ws.delete_current_session()
382
+ except Exception: # pragma: no cover — defensive
383
+ logger.exception("ClaudeRunner: delete_current_session failed")
384
+ # (c) IPC architecture upgrade — clear from memory map.
385
+ if self._agent_to_session is not None:
386
+ self._agent_to_session.pop(agent_id, None)
387
+
388
+ captured_session = attempt.captured_session_id
389
+ rc = attempt.exit_code
390
+ timeout_hit = attempt.timeout_hit
391
+ stderr_tail = attempt.stderr_tail
392
+
393
+ # Spec §6.6 step 2 — token is one-shot per wake.
394
+ try:
395
+ ws.delete_token_file()
396
+ except Exception: # pragma: no cover — defensive
397
+ logger.exception("ClaudeRunner: delete_token_file failed")
398
+
399
+ if captured_session and captured_session != ws.last_session_id():
400
+ ws.set_last_session_id(captured_session)
401
+ await self.outbound.send_agent_session_start(
402
+ agent_id, captured_session
403
+ )
404
+
405
+ # 1k #63 Layer B — daemon-side fallback ``send_message``.
406
+ #
407
+ # Even with the stdin wrapper (Layer A) claude may occasionally
408
+ # still print a reply to stdout without invoking
409
+ # ``mcp__cloudagent__send_message`` (e.g. for trivial prompts
410
+ # like "你好" / "你是谁"). When that happens the daemon discards
411
+ # stdout and the user-facing inbox stays empty.
412
+ #
413
+ # If claude exited cleanly (rc==0, no timeout) AND produced final
414
+ # result text AND did NOT call send_message, we POST the reply
415
+ # ourselves so the user always sees it. We use the per-deliver
416
+ # session_id from the payload — the same id mcp_bridge would
417
+ # have read from current-session.txt if claude had called the
418
+ # tool itself. The backend merges session_id from the frame
419
+ # top-level into args before dispatching ``send_message`` (1k
420
+ # #56), so we don't need to inject it into the args dict.
421
+ called_send_message = any(
422
+ name == "mcp__cloudagent__send_message"
423
+ for name in attempt.tool_uses
424
+ )
425
+ fallback_session = payload.get("session_id")
426
+ if (
427
+ not called_send_message
428
+ and rc == 0
429
+ and not timeout_hit
430
+ and attempt.final_result_text
431
+ and isinstance(fallback_session, str)
432
+ and fallback_session
433
+ ):
434
+ logger.warning(
435
+ "claude_runner: claude wake exited without calling "
436
+ "send_message — daemon fallback POSTing result text "
437
+ "(agent=%s session=%s text_chars=%d)",
438
+ agent_id,
439
+ fallback_session,
440
+ len(attempt.final_result_text),
441
+ )
442
+ try:
443
+ await self.outbound.send_tool_call(
444
+ agent_id=agent_id,
445
+ session_id=fallback_session,
446
+ tool_name="send_message",
447
+ args={
448
+ "target": "user",
449
+ "body": attempt.final_result_text,
450
+ "kind": "reply",
451
+ },
452
+ # Backend dispatch is fast — short timeout keeps the
453
+ # wake from blocking the spawn queue if the WS round
454
+ # trip stalls.
455
+ timeout=15.0,
456
+ )
457
+ # Wave C #3 — non-zero in /metrics means the 1k #63
458
+ # Layer A stdin template slipped; operators should
459
+ # investigate. Increment AFTER the POST succeeded so
460
+ # we don't double-count crashloops.
461
+ try:
462
+ from cloudagent_daemon.metrics import get_default as _md
463
+ await _md().inc_counter(
464
+ "cloudagent_daemon_spawn_fallback_send_message_total"
465
+ )
466
+ except Exception: # noqa: BLE001
467
+ pass
468
+ except Exception: # pragma: no cover — best-effort fallback
469
+ logger.exception(
470
+ "claude_runner: daemon fallback send_message FAILED "
471
+ "(lost reply) agent=%s session=%s",
472
+ agent_id,
473
+ fallback_session,
474
+ )
475
+
476
+ ws.write_last_spawn(rc, captured_session, stderr_tail)
477
+
478
+ if timeout_hit:
479
+ final_status = "timeout"
480
+ elif rc == 0:
481
+ final_status = "idle"
482
+ else:
483
+ final_status = "crashed"
484
+
485
+ # Wave C #3 — surface terminal status counts for /metrics +
486
+ # doctor. Label-keyed so a single counter renders three
487
+ # series (idle / crashed / timeout). Failure to publish must
488
+ # never crash the wake — the wake itself is the user-facing
489
+ # contract.
490
+ try:
491
+ from cloudagent_daemon.metrics import get_default as _md
492
+ await _md().inc_counter(
493
+ "cloudagent_daemon_spawn_completed_total",
494
+ labels={"status": final_status},
495
+ )
496
+ except Exception: # noqa: BLE001
497
+ logger.debug(
498
+ "claude_runner: spawn_completed_total publish failed",
499
+ exc_info=True,
500
+ )
501
+
502
+ await self.outbound.send_agent_status(
503
+ agent_id, final_status, exit_code=rc
504
+ )
505
+
506
+ wake_secs = time.monotonic() - wake_start
507
+ logger.info(
508
+ "claude_runner: claude exited agent=%s rc=%d session_id=%s "
509
+ "status=%s wake_secs=%.2f stderr_tail_chars=%d",
510
+ agent_id,
511
+ rc,
512
+ captured_session,
513
+ final_status,
514
+ wake_secs,
515
+ len(stderr_tail),
516
+ )
517
+
518
+ return SpawnResult(
519
+ exit_code=rc,
520
+ captured_session_id=captured_session,
521
+ stderr_tail=stderr_tail,
522
+ )
523
+
524
+ async def _spawn_attempt(
525
+ self,
526
+ ws: AgentWorkspaceLike,
527
+ agent_id: str,
528
+ payload: dict,
529
+ *,
530
+ with_resume: bool,
531
+ ) -> _SpawnAttempt:
532
+ """Run a single ``claude`` subprocess and return its outcome.
533
+
534
+ Extracted from :meth:`run_one` so the runner can re-invoke it
535
+ with ``with_resume=False`` when the first attempt fails with a
536
+ stale-session error (1k followup).
537
+
538
+ Side-effects emitted exactly once per attempt: ``delivery_ack``
539
+ for the message_id, and ``agent_status(running)``. The final
540
+ ``agent_status`` (idle/crashed/timeout) and last-spawn.json
541
+ write are intentionally LEFT to the caller so they always
542
+ reflect the *last* attempt.
543
+ """
544
+ cmd = self._build_cmd(ws, agent_id, with_resume=with_resume)
545
+
546
+ # Task #118 (2026-05-12) — spawn cwd is now the agent-specific
547
+ # cwd (per-agent override → host default → platform fallback).
548
+ # Pre-#118 the daemon inherited its own cwd (typically ``~``)
549
+ # which meant claude saw HOME as its working dir regardless of
550
+ # the agent's intended project — broken UX for "let agent A
551
+ # work on ~/projects/foo".
552
+ spawn_cwd = ws.cwd
553
+
554
+ # 1i Bug C — Phase 1 demo visibility.
555
+ logger.info(
556
+ "claude_runner: spawning claude agent=%s msg=%s prior_session=%s "
557
+ "with_resume=%s cwd=%s cmd=%s",
558
+ agent_id,
559
+ payload.get("message_id"),
560
+ ws.last_session_id(),
561
+ with_resume,
562
+ spawn_cwd,
563
+ " ".join(cmd),
564
+ )
565
+
566
+ logger.debug("ClaudeRunner: spawning %s", cmd)
567
+ proc = await asyncio.create_subprocess_exec(
568
+ *cmd,
569
+ stdin=asyncio.subprocess.PIPE,
570
+ stdout=asyncio.subprocess.PIPE,
571
+ stderr=asyncio.subprocess.PIPE,
572
+ cwd=str(spawn_cwd),
573
+ # asyncio StreamReader 默认 limit=64KB;claude CLI 单条 JSON
574
+ # event 可超出(tool_result 含大型 Read 文件 / Anthropic 返回
575
+ # body / partial_messages 累积 chunk 等)。2026-05-14 实战:
576
+ # local daemon 在执行 SDK/CLI 开发 task 时 readline 抛
577
+ # LimitOverrunError "Separator is found, but chunk is longer
578
+ # than limit" → spawn fail,claude 没机会发 send_message 回复
579
+ # → 上游 task 永远卡 waiting/partial。对齐 backend harness 的
580
+ # claude_code_core.py 同步把 limit 提到 10MB。
581
+ limit=10 * 1024 * 1024,
582
+ )
583
+
584
+ # 1k #63 Layer A — wrap the message body in a tool-only contract.
585
+ # stdin is the active user prompt that claude can't treat as a
586
+ # soft footer (unlike ``--append-system-prompt``). See
587
+ # ``WAKE_PROMPT_TEMPLATE`` docstring.
588
+ body = payload.get("body") or ""
589
+ header = self._build_passthrough_header(payload)
590
+ wake_prompt = self.WAKE_PROMPT_TEMPLATE.format(
591
+ header=header, body=body
592
+ )
593
+ try:
594
+ assert proc.stdin is not None # for type checkers
595
+ proc.stdin.write(wake_prompt.encode("utf-8"))
596
+ proc.stdin.write(b"\n")
597
+ await proc.stdin.drain()
598
+ proc.stdin.close()
599
+ except (BrokenPipeError, ConnectionResetError):
600
+ # claude died before we finished writing; downstream code will
601
+ # observe the exit code and stderr.
602
+ logger.debug("ClaudeRunner: stdin pipe closed early")
603
+
604
+ # Notify backend that we picked the message up.
605
+ message_id = payload.get("message_id")
606
+ if message_id:
607
+ await self.outbound.send_ack(
608
+ seq=None, message_id=message_id, status="started"
609
+ )
610
+ await self.outbound.send_agent_status(agent_id, "running")
611
+
612
+ # 1k74 — token-level streaming. Forward the deliver's session_id
613
+ # and agent_id into the stdout consumer so it can call our
614
+ # partial_publisher with the right routing key for each text
615
+ # chunk. session_id is ``test_chat_{host}_{agent}`` for test-
616
+ # chat deliveries (the new per-agent shape from 1k73).
617
+ deliver_session_id = payload.get("session_id")
618
+ captured_session, tool_uses, final_text = await self._consume_stdout(
619
+ proc,
620
+ partial_session_id=(
621
+ deliver_session_id
622
+ if isinstance(deliver_session_id, str)
623
+ else None
624
+ ),
625
+ partial_agent_id=agent_id,
626
+ )
627
+
628
+ timeout_hit, rc = await self._wait_with_timeout(proc)
629
+
630
+ # stderr was buffered by the subprocess; drain whatever is left.
631
+ stderr_b = await proc.stderr.read() if proc.stderr is not None else b""
632
+ stderr_tail = stderr_b.decode("utf-8", errors="replace")[
633
+ -self.STDERR_TAIL_BYTES:
634
+ ]
635
+
636
+ return _SpawnAttempt(
637
+ exit_code=rc,
638
+ captured_session_id=captured_session,
639
+ stderr_tail=stderr_tail,
640
+ timeout_hit=timeout_hit,
641
+ tool_uses=tool_uses,
642
+ final_result_text=final_text,
643
+ )
644
+
645
+ # ------------------------------------------------------------------
646
+ # Internal helpers
647
+ # ------------------------------------------------------------------
648
+
649
+ #: 1i Bug E — the daemon-injected ``cloudagent`` MCP toolset MUST run
650
+ #: unattended in ``--print`` headless mode (otherwise claude prompts
651
+ #: the user for permission, the prompt is never answered, and the
652
+ #: wake exits without the reply being delivered). We allow exactly
653
+ #: this toolset rather than ``--dangerously-skip-permissions`` (which
654
+ #: would also whitelist Bash/Edit/etc. — too broad).
655
+ CLOUDAGENT_MCP_ALLOWED_TOOLS: tuple[str, ...] = (
656
+ "mcp__cloudagent__send_message",
657
+ "mcp__cloudagent__list_messages",
658
+ "mcp__cloudagent__upload_file",
659
+ "mcp__cloudagent__schedule_reminder",
660
+ "mcp__cloudagent__delegate_to_staff",
661
+ "mcp__cloudagent__close_task",
662
+ "mcp__cloudagent__request_user_approval",
663
+ "mcp__cloudagent__set_company_status",
664
+ )
665
+
666
+ # 1i Bug F — operational contract for daemon-spawned wakes.
667
+ # Without this, claude in --print mode just dumps its answer to stdout
668
+ # and exits, never invoking send_message — so the user never sees a reply.
669
+ # The daemon discards stdout text; only mcp tool calls flow back through
670
+ # the outbound queue to the user. We inject this as --append-system-prompt
671
+ # so it runs after the default Claude Code system prompt and the user
672
+ # cannot edit it away from MEMORY.md / settings.
673
+ SYSTEM_PROMPT_APPEND: str = """
674
+ You are an AI agent running inside cloudagent's local daemon. The user message you are receiving via stdin is a deliver from your agent inbox (agent_id={agent_id}).
675
+
676
+ ROUTING — choosing `target` for send_message (HARD RULE):
677
+ Every kind="reply" send MUST set BOTH `target` AND `parent_msg_id`
678
+ by reading the inbound message's [meta] header — never by defaulting
679
+ to "user". The [meta] header contains the sender's role and
680
+ message_id; copy them verbatim into your tool call:
681
+
682
+ target = inbound `from` / `sender_role` field
683
+ parent_msg_id = inbound `message_id` field
684
+
685
+ Concrete cases:
686
+ - Inbound `from`="user" → target="user" (human HITL surface)
687
+ - Inbound `from`="ceo" → target="ceo" (chief↔staff collab)
688
+ - Inbound `from`="<peer staff role>" → target=<that role>
689
+
690
+ Do NOT default target="user" when a non-user agent dispatched the
691
+ work — that breaks chief↔staff collaboration AND the parent task
692
+ hangs in `substatus=partial` because the chief's wait_for_replies
693
+ never sees your reply. The backend has two safety nets (parent_msg_id
694
+ →to_agent derivation; orphan-reply implicit inbox lookup within 5
695
+ min) but they are belt-and-braces — your tool call MUST be correct.
696
+ When unsure which agent dispatched the work, call
697
+ `mcp__cloudagent__list_messages(limit=20)` first and copy `from` /
698
+ `message_id` from the most recent inbound row addressed to you.
699
+
700
+ THREADING — parent_msg_id (hot-fix 2026-05-12, CRITICAL):
701
+ When replying with kind="reply" or kind="question" you MUST set
702
+ parent_msg_id to the inbound message's `message_id` (shown in the
703
+ [meta] header above your stdin payload, e.g. `message_id: 236`).
704
+ Failing this, the chief's wait_for_replies will never satisfy and
705
+ the parent task hangs forever in `partial` substatus. Backend's
706
+ parent_msg_id-based to_agent inference IS a safety net for routing,
707
+ but the chief's wait_for_replies looks up replies BY parent_msg_id —
708
+ if you drop it, the chief simply never knows your reply exists.
709
+ Unsolicited kind="task" / kind="report" sends leave parent_msg_id
710
+ unset (None / omitted).
711
+
712
+ INBOX SWEEP — on every boot:
713
+ Your FIRST action MUST be `mcp__cloudagent__list_messages(limit=20)`.
714
+ Scan the result for any **unread** messages addressed to you that are
715
+ NOT the deliver you just received via stdin. Handle each in order
716
+ (call send_message to reply per the routing rules above) BEFORE you
717
+ respond to the stdin deliver. This catches any frames the daemon
718
+ failed to spawn while you were offline.
719
+
720
+ To reply, call mcp__cloudagent__send_message with:
721
+ - target: per the routing rules above
722
+ - body: your reply text
723
+ - kind: "reply" (or "question" if you need clarification)
724
+
725
+ Do NOT write your reply directly to stdout. The daemon ignores stdout text — only mcp tool calls reach the user / other agents.
726
+
727
+ After all sends are issued, exit naturally. Your wake will be marked complete.
728
+
729
+ Available cloudagent tools:
730
+ - mcp__cloudagent__send_message(target, body, kind, parent_msg_id) — send to user or another agent.
731
+ For kind="reply" / "question", parent_msg_id MUST be the inbound
732
+ message_id from [meta] (see THREADING above). Omit / leave None
733
+ for unsolicited kind="task" / "report" sends.
734
+ - mcp__cloudagent__list_messages(limit) — read recent inbox (status field discriminates unread/read)
735
+ - mcp__cloudagent__upload_file(path) — share a file
736
+ - mcp__cloudagent__schedule_reminder(when, body) — schedule self-reminder
737
+ """.strip()
738
+
739
+ def _build_passthrough_header(self, payload: dict) -> str:
740
+ """Render the D2 passthrough header for the wake prompt.
741
+
742
+ Emits a single ``[meta]`` block with one ``key: value`` line per
743
+ ``_PASSTHROUGH_HEADER_FIELDS`` entry **that the payload provides
744
+ as a non-empty string-coercible value**. Empty when no fields are
745
+ present (so the existing pre-Wave-C behaviour is byte-identical
746
+ for legacy backends that don't stamp these fields yet).
747
+
748
+ Newline-terminated when non-empty so it composes cleanly into
749
+ ``WAKE_PROMPT_TEMPLATE`` immediately above the body.
750
+ """
751
+ lines: list[str] = []
752
+ for key in self._PASSTHROUGH_HEADER_FIELDS:
753
+ if key not in payload:
754
+ continue
755
+ value = payload.get(key)
756
+ if value in (None, ""):
757
+ continue
758
+ # Coerce to a single-line string. Multi-line values are
759
+ # collapsed defensively — daemon doesn't interpret these
760
+ # fields, but a stray newline in a header would mis-frame the
761
+ # body for claude.
762
+ text = str(value).replace("\r", " ").replace("\n", " ").strip()
763
+ if not text:
764
+ continue
765
+ lines.append(f"{key}: {text}")
766
+ if not lines:
767
+ return ""
768
+ return "[meta]\n" + "\n".join(lines) + "\n[/meta]\n\n"
769
+
770
+ def _build_cmd(
771
+ self,
772
+ ws: AgentWorkspaceLike,
773
+ agent_id: str,
774
+ *,
775
+ with_resume: bool = True,
776
+ ) -> list[str]:
777
+ """Build the ``claude`` argv list for one wake.
778
+
779
+ 1k followup — ``with_resume=False`` forces the runner to omit
780
+ ``--resume`` even if a stored ``last_session_id`` exists. The
781
+ retry path uses this after a stale-session crash.
782
+ """
783
+ cmd: list[str] = [self.claude_binary]
784
+ last_session = ws.last_session_id() if with_resume else None
785
+ if last_session:
786
+ cmd += ["--resume", last_session]
787
+ # 1i Bug F — inject operational contract via --append-system-prompt.
788
+ system_prompt = self.SYSTEM_PROMPT_APPEND.format(agent_id=agent_id)
789
+ cmd += [
790
+ "--mcp-config",
791
+ str(ws.mcp_config_path()),
792
+ "--print",
793
+ "--verbose",
794
+ "--output-format",
795
+ "stream-json",
796
+ # 1k74 (2026-05-11) — token-level streaming. Without this flag
797
+ # claude buffers each ``assistant`` message and emits it in
798
+ # one event (full reply, 100% buffered). With it the runtime
799
+ # ALSO emits ``stream_event`` events carrying
800
+ # ``content_block_delta`` / ``text_delta`` chunks (~80
801
+ # chars/chunk) as the model generates. The daemon forwards
802
+ # those chunks to backend so the chat bubble fills in
803
+ # token-by-token instead of jumping straight to the final
804
+ # reply after a 30s blank stare.
805
+ "--include-partial-messages",
806
+ # 1i Bug E — see CLOUDAGENT_MCP_ALLOWED_TOOLS docstring.
807
+ "--allowedTools",
808
+ ",".join(self.CLOUDAGENT_MCP_ALLOWED_TOOLS),
809
+ # 1i Bug F — see SYSTEM_PROMPT_APPEND docstring.
810
+ "--append-system-prompt",
811
+ system_prompt,
812
+ ]
813
+ return cmd
814
+
815
+ async def _consume_stdout(
816
+ self,
817
+ proc: asyncio.subprocess.Process,
818
+ *,
819
+ partial_session_id: str | None = None,
820
+ partial_agent_id: str | None = None,
821
+ ) -> tuple[str | None, list[str], str | None]:
822
+ """Stream NDJSON from stdout; return ``(session_id, tool_uses, result_text)``.
823
+
824
+ Accepts BOTH session-init event shapes for forward/backward
825
+ compatibility:
826
+
827
+ * Old/spec shape: ``{"type": "session_init", "session_id": "..."}``
828
+ * Real claude (≥ 2024-Q4) shape:
829
+ ``{"type": "system", "subtype": "init", "session_id": "..."}``
830
+
831
+ 1k #63 — additionally extracts:
832
+
833
+ * ``tool_uses``: list of every tool name that appears in any
834
+ assistant ``content[*].type == "tool_use"`` block AND in any
835
+ standalone ``type == "tool_use"`` event (claude CLI varies
836
+ across versions). Used by the daemon-side fallback to decide
837
+ whether claude actually called ``mcp__cloudagent__send_message``.
838
+ * ``final_result_text``: the text from the terminating
839
+ ``type == "result"`` event. Falls back to ``evt.get("text")``
840
+ if ``evt.get("result")`` is empty (different claude versions
841
+ name the same field differently). Used as the body of the
842
+ fallback ``send_message`` POST.
843
+
844
+ Also surfaces ``type=result`` and ``type=tool_use`` events at INFO
845
+ for Phase 1 demo visibility (1i Bug C).
846
+ """
847
+ captured: str | None = None
848
+ tool_uses: list[str] = []
849
+ final_text: str | None = None
850
+ # 1k74 — running buffer for token-level streaming. claude emits
851
+ # one ``stream_event`` per content_block_delta (~80 chars at a
852
+ # time); we concatenate them and forward the running total to
853
+ # the publisher so the receiver renders the bubble in growing
854
+ # form rather than having to track its own deltas.
855
+ partial_buffer: list[str] = []
856
+ publish_enabled = bool(
857
+ self._partial_publisher
858
+ and partial_session_id
859
+ and partial_agent_id
860
+ )
861
+ # (d) cache token accumulators — populated from Anthropic API
862
+ # ``message_start`` stream event wrapped in ``stream_event``.
863
+ _cache_read_tokens: int = 0
864
+ _cache_creation_tokens: int = 0
865
+ if proc.stdout is None:
866
+ return None, tool_uses, final_text
867
+
868
+ async for line_b in proc.stdout:
869
+ line = line_b.decode("utf-8", errors="replace").strip()
870
+ if not line:
871
+ continue
872
+ try:
873
+ evt = json.loads(line)
874
+ except json.JSONDecodeError:
875
+ logger.debug("ClaudeRunner: non-JSON stdout line: %r", line[:120])
876
+ continue
877
+ if not isinstance(evt, dict):
878
+ continue
879
+
880
+ evt_type = evt.get("type")
881
+ evt_subtype = evt.get("subtype")
882
+
883
+ # 1k74 — token-level streaming. claude emits one or more
884
+ # ``stream_event`` events per assistant message, each
885
+ # wrapping an Anthropic Messages API event. The shape we
886
+ # care about is:
887
+ # {"type":"stream_event","event":{
888
+ # "type":"content_block_delta",
889
+ # "delta":{"type":"text_delta","text":"..."}}}
890
+ # Each ``text`` is the latest chunk; concatenating them in
891
+ # arrival order rebuilds the assistant's eventual reply.
892
+ if evt_type == "stream_event":
893
+ ev = evt.get("event")
894
+ if isinstance(ev, dict):
895
+ # (d) cache token observability — extract usage from
896
+ # ``message_start`` which Anthropic emits once per
897
+ # response with input/cache token counts.
898
+ if ev.get("type") == "message_start":
899
+ msg_u = (ev.get("message") or {}).get("usage") or {}
900
+ if isinstance(msg_u, dict):
901
+ _cache_read_tokens += int(
902
+ msg_u.get("cache_read_input_tokens") or 0
903
+ )
904
+ _cache_creation_tokens += int(
905
+ msg_u.get("cache_creation_input_tokens") or 0
906
+ )
907
+ # 1k74 — token-level streaming.
908
+ if publish_enabled and ev.get("type") == "content_block_delta":
909
+ delta = ev.get("delta")
910
+ if (
911
+ isinstance(delta, dict)
912
+ and delta.get("type") == "text_delta"
913
+ ):
914
+ chunk = delta.get("text")
915
+ if isinstance(chunk, str) and chunk:
916
+ partial_buffer.append(chunk)
917
+ accumulated = "".join(partial_buffer)
918
+ # Fire-and-forget — exceptions logged and
919
+ # swallowed so a flaky publish path can't
920
+ # tear down the wake.
921
+ assert self._partial_publisher is not None
922
+ assert partial_session_id is not None
923
+ assert partial_agent_id is not None
924
+ try:
925
+ await self._partial_publisher(
926
+ partial_session_id,
927
+ partial_agent_id,
928
+ accumulated,
929
+ )
930
+ except Exception as exc: # noqa: BLE001
931
+ logger.info(
932
+ "partial_publisher failed (non-fatal): %r",
933
+ exc,
934
+ )
935
+
936
+ # Capture session_id from either of the known event shapes.
937
+ is_session_event = (
938
+ evt_type == "session_init"
939
+ or (evt_type == "system" and evt_subtype == "init")
940
+ )
941
+ if is_session_event and captured is None:
942
+ # Tolerate both snake_case and camelCase keys.
943
+ sid = evt.get("session_id") or evt.get("sessionId")
944
+ if isinstance(sid, str) and sid:
945
+ captured = sid
946
+ logger.info(
947
+ "ClaudeRunner: captured session_id=%s (event type=%s subtype=%s)",
948
+ sid,
949
+ evt_type,
950
+ evt_subtype,
951
+ )
952
+
953
+ # 1k #63 — extract tool_use names from assistant content blocks.
954
+ # The real claude CLI emits tool_use as a content block inside
955
+ # an ``assistant`` event:
956
+ # {"type":"assistant","message":{"content":[
957
+ # {"type":"tool_use","name":"mcp__cloudagent__send_message",...}
958
+ # ]}}
959
+ if evt_type == "assistant":
960
+ msg = evt.get("message")
961
+ content = (
962
+ msg.get("content")
963
+ if isinstance(msg, dict)
964
+ else None
965
+ )
966
+ if isinstance(content, list):
967
+ for block in content:
968
+ if (
969
+ isinstance(block, dict)
970
+ and block.get("type") == "tool_use"
971
+ ):
972
+ name = block.get("name") or block.get("tool_name")
973
+ if isinstance(name, str) and name:
974
+ tool_uses.append(name)
975
+
976
+ # 1i Bug C — Phase 1 demo visibility for key event types.
977
+ if evt_type == "result":
978
+ # 1k #63 — capture the final reply text for the fallback
979
+ # path. ``result`` is the canonical field on the
980
+ # terminating event; ``text`` is a fallback some versions
981
+ # use. We also capture this for visibility-only logging.
982
+ result_text = evt.get("result")
983
+ if not isinstance(result_text, str) or not result_text:
984
+ alt = evt.get("text")
985
+ if isinstance(alt, str) and alt:
986
+ result_text = alt
987
+ if isinstance(result_text, str) and result_text:
988
+ final_text = result_text
989
+ summary = (
990
+ result_text[:120] + "..."
991
+ if isinstance(result_text, str) and len(result_text) > 120
992
+ else result_text
993
+ )
994
+ logger.info(
995
+ "ClaudeRunner: stdout result subtype=%s text=%r",
996
+ evt_subtype,
997
+ summary,
998
+ )
999
+ elif evt_type == "tool_use":
1000
+ # Standalone tool_use event (older / alternate shape).
1001
+ name = evt.get("name") or evt.get("tool_name")
1002
+ if isinstance(name, str) and name:
1003
+ tool_uses.append(name)
1004
+ logger.info(
1005
+ "ClaudeRunner: stdout tool_use name=%s",
1006
+ name,
1007
+ )
1008
+ # (d) emit cache token metrics once per wake, after stdout drains.
1009
+ if _cache_read_tokens or _cache_creation_tokens:
1010
+ logger.info(
1011
+ "claude_runner: cache_tokens "
1012
+ "cache_read=%d cache_creation=%d "
1013
+ "metric_event=cloudagent_llm_cache_hit_tokens_total",
1014
+ _cache_read_tokens,
1015
+ _cache_creation_tokens,
1016
+ )
1017
+ try:
1018
+ from cloudagent_daemon.metrics import get_default as _get_metrics
1019
+ _m = _get_metrics()
1020
+ await _m.inc_counter(
1021
+ "cloudagent_llm_cache_hit_tokens_total",
1022
+ n=_cache_read_tokens,
1023
+ labels={"source": "daemon"},
1024
+ )
1025
+ await _m.inc_counter(
1026
+ "cloudagent_llm_cache_creation_tokens_total",
1027
+ n=_cache_creation_tokens,
1028
+ labels={"source": "daemon"},
1029
+ )
1030
+ except Exception: # noqa: BLE001 — non-fatal metric failure
1031
+ logger.debug(
1032
+ "claude_runner: cache metrics publish failed", exc_info=True
1033
+ )
1034
+
1035
+ return captured, tool_uses, final_text
1036
+
1037
+ async def _wait_with_timeout(
1038
+ self, proc: asyncio.subprocess.Process
1039
+ ) -> tuple[bool, int]:
1040
+ """Wait for exit; SIGTERM→SIGKILL on timeout. Returns (timeout_hit, rc)."""
1041
+ try:
1042
+ rc = await asyncio.wait_for(
1043
+ proc.wait(), timeout=self._single_wake_timeout
1044
+ )
1045
+ return False, rc
1046
+ except asyncio.TimeoutError:
1047
+ logger.warning("ClaudeRunner: 90-min wake timeout, terminating")
1048
+
1049
+ # First polite SIGTERM.
1050
+ try:
1051
+ proc.terminate()
1052
+ except ProcessLookupError: # pragma: no cover — already gone
1053
+ pass
1054
+ try:
1055
+ rc = await asyncio.wait_for(
1056
+ proc.wait(), timeout=self.TERMINATE_GRACE_SECONDS
1057
+ )
1058
+ return True, rc
1059
+ except asyncio.TimeoutError:
1060
+ pass
1061
+
1062
+ # Hard kill.
1063
+ try:
1064
+ proc.kill()
1065
+ except ProcessLookupError: # pragma: no cover
1066
+ pass
1067
+ rc = await proc.wait()
1068
+ return True, rc