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.
- cloudagent_daemon/__init__.py +1 -0
- cloudagent_daemon/__main__.py +4 -0
- cloudagent_daemon/claude_runner.py +1068 -0
- cloudagent_daemon/cli.py +833 -0
- cloudagent_daemon/config.py +197 -0
- cloudagent_daemon/doctor.py +534 -0
- cloudagent_daemon/frame_dispatch.py +216 -0
- cloudagent_daemon/http_outbound.py +80 -0
- cloudagent_daemon/jsonl_logging.py +98 -0
- cloudagent_daemon/login.py +34 -0
- cloudagent_daemon/main_loop.py +891 -0
- cloudagent_daemon/mcp_bridge.py +441 -0
- cloudagent_daemon/metrics.py +315 -0
- cloudagent_daemon/metrics_server.py +149 -0
- cloudagent_daemon/outbound_queue.py +289 -0
- cloudagent_daemon/paths.py +204 -0
- cloudagent_daemon/protocol.py +141 -0
- cloudagent_daemon/runtime_detect.py +135 -0
- cloudagent_daemon/service.py +355 -0
- cloudagent_daemon/spawn_outbox.py +443 -0
- cloudagent_daemon/spawn_queue.py +200 -0
- cloudagent_daemon/sse_client.py +439 -0
- cloudagent_daemon/transport.py +105 -0
- cloudagent_daemon/unix_socket.py +278 -0
- cloudagent_daemon/workspace.py +441 -0
- cloudagent_daemon/ws_client.py +431 -0
- cloudagent_daemon-0.2.1.dist-info/METADATA +322 -0
- cloudagent_daemon-0.2.1.dist-info/RECORD +31 -0
- cloudagent_daemon-0.2.1.dist-info/WHEEL +4 -0
- cloudagent_daemon-0.2.1.dist-info/entry_points.txt +2 -0
- cloudagent_daemon-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Outbound frame queue for the cloudagent daemon.
|
|
2
|
+
|
|
3
|
+
Serializes all outgoing daemon→server sends through a lock (concurrent
|
|
4
|
+
spawn supervisors may call simultaneously — must avoid interleaved JSON
|
|
5
|
+
on the wire). Also provides high-level helpers for each outbound frame
|
|
6
|
+
type. The actual wire (WebSocket vs HTTP-POST) is hidden behind a
|
|
7
|
+
``Transport`` protocol — see ``transport.py``.
|
|
8
|
+
|
|
9
|
+
Spec §4.2 / §4.3.
|
|
10
|
+
|
|
11
|
+
Design note on pending_tool_calls ownership
|
|
12
|
+
-------------------------------------------
|
|
13
|
+
OutboundQueue creates and owns ``pending_tool_calls``. FrameDispatcher
|
|
14
|
+
accepts it via its ``tool_result_pending`` constructor parameter so that
|
|
15
|
+
incoming ``tool_result`` frames resolve the same futures that
|
|
16
|
+
``send_tool_call`` is awaiting. Caller wires them together::
|
|
17
|
+
|
|
18
|
+
oq = OutboundQueue(transport)
|
|
19
|
+
dispatcher = FrameDispatcher(..., tool_result_pending=oq.pending_tool_calls)
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import logging
|
|
25
|
+
from datetime import UTC, datetime
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
from uuid import uuid4
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from cloudagent_daemon.transport import Transport
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _new_frame_id() -> str:
|
|
36
|
+
return f"frm_{uuid4().hex[:16]}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _new_request_id() -> str:
|
|
40
|
+
return f"req_{uuid4().hex}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _now_iso() -> str:
|
|
44
|
+
return datetime.now(UTC).isoformat()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OutboundQueue:
|
|
48
|
+
"""Serialize outbound frames and provide frame-type helpers.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
transport:
|
|
53
|
+
A ``Transport`` (WS or HTTP) — anything with
|
|
54
|
+
``async send_frame(frame: dict)``. ``None`` is allowed for
|
|
55
|
+
construction-before-connect; sends will raise until ``transport``
|
|
56
|
+
is rebound. Tests may pass a fake with a ``send`` method via the
|
|
57
|
+
legacy positional ``ws=`` keyword for back-compat (deprecated;
|
|
58
|
+
prefer ``transport=``).
|
|
59
|
+
default_tool_call_timeout:
|
|
60
|
+
Seconds to wait for a ``tool_result`` before returning a synthetic
|
|
61
|
+
``SERVER_TIMEOUT`` response.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
transport: "Transport | None" = None,
|
|
67
|
+
*,
|
|
68
|
+
ws: object = None, # legacy alias — accepts old WSClient or test
|
|
69
|
+
# FakeWS objects with .send(frame)
|
|
70
|
+
default_tool_call_timeout: float = 30.0,
|
|
71
|
+
pending_tool_calls: dict[str, asyncio.Future] | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
# Back-compat: pre-1j call-sites and unit tests pass a WSClient or
|
|
74
|
+
# FakeWS as the first positional arg. Detect that shape (has
|
|
75
|
+
# ``send`` but no ``send_frame``) and wrap it with the legacy
|
|
76
|
+
# adapter so existing tests keep passing without edits.
|
|
77
|
+
if transport is not None and not hasattr(transport, "send_frame"):
|
|
78
|
+
if hasattr(transport, "send"):
|
|
79
|
+
transport = _LegacyWSAdapter(transport)
|
|
80
|
+
else:
|
|
81
|
+
# Anything else is a programming error — let it fail loudly
|
|
82
|
+
# downstream rather than silently produce a broken queue.
|
|
83
|
+
pass
|
|
84
|
+
if transport is None and ws is not None:
|
|
85
|
+
transport = _LegacyWSAdapter(ws)
|
|
86
|
+
self.transport: "Transport | None" = transport
|
|
87
|
+
self.default_tool_call_timeout = default_tool_call_timeout
|
|
88
|
+
self._send_lock = asyncio.Lock()
|
|
89
|
+
# Shared with FrameDispatcher — see module docstring. Accepting it as
|
|
90
|
+
# a constructor parameter lets ``main_loop.run_daemon`` build the dict
|
|
91
|
+
# once and pass the same instance to both the dispatcher and the
|
|
92
|
+
# outbound queue, avoiding a "rebind on connect" dance for the dict.
|
|
93
|
+
self.pending_tool_calls: dict[str, asyncio.Future] = (
|
|
94
|
+
pending_tool_calls if pending_tool_calls is not None else {}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def ws(self) -> object:
|
|
99
|
+
"""Legacy alias kept for ``main_loop._on_connect`` compatibility.
|
|
100
|
+
|
|
101
|
+
Reads return the underlying WSClient when running on the legacy
|
|
102
|
+
WS transport; ``None`` for HTTP/SSE or before connect. Writes
|
|
103
|
+
rebind the transport via ``_LegacyWSAdapter`` so existing
|
|
104
|
+
``outbound.ws = client`` call-sites keep working unchanged.
|
|
105
|
+
"""
|
|
106
|
+
if isinstance(self.transport, _LegacyWSAdapter):
|
|
107
|
+
return self.transport._ws
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
@ws.setter
|
|
111
|
+
def ws(self, value: object) -> None:
|
|
112
|
+
if value is None:
|
|
113
|
+
self.transport = None
|
|
114
|
+
return
|
|
115
|
+
# If the caller hands us something that already looks like a
|
|
116
|
+
# Transport (has ``send_frame``), use it directly. Otherwise
|
|
117
|
+
# adapt a WSClient-shaped object.
|
|
118
|
+
if hasattr(value, "send_frame"):
|
|
119
|
+
self.transport = value # type: ignore[assignment]
|
|
120
|
+
else:
|
|
121
|
+
self.transport = _LegacyWSAdapter(value)
|
|
122
|
+
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
# High-level helpers
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
async def send_ack(
|
|
128
|
+
self,
|
|
129
|
+
seq: int | None,
|
|
130
|
+
message_id: str,
|
|
131
|
+
status: str,
|
|
132
|
+
retry_after_ms: int | None = None,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Send a ``delivery_ack`` frame.
|
|
135
|
+
|
|
136
|
+
*seq=None* for non-deliver acks; the field is omitted from the
|
|
137
|
+
payload in that case (backend accepts missing seq).
|
|
138
|
+
"""
|
|
139
|
+
payload: dict = {"message_id": message_id, "status": status}
|
|
140
|
+
if seq is not None:
|
|
141
|
+
payload["seq"] = seq
|
|
142
|
+
if retry_after_ms is not None:
|
|
143
|
+
payload["retry_after_ms"] = retry_after_ms
|
|
144
|
+
|
|
145
|
+
await self.send_raw({
|
|
146
|
+
"v": 1,
|
|
147
|
+
"type": "delivery_ack",
|
|
148
|
+
"id": _new_frame_id(),
|
|
149
|
+
"ts": _now_iso(),
|
|
150
|
+
"payload": payload,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
async def send_agent_status(
|
|
154
|
+
self,
|
|
155
|
+
agent_id: str,
|
|
156
|
+
status: str,
|
|
157
|
+
exit_code: int | None = None,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Send an ``agent_status`` frame."""
|
|
160
|
+
payload: dict = {"agent_id": agent_id, "status": status}
|
|
161
|
+
if exit_code is not None:
|
|
162
|
+
payload["exit_code"] = exit_code
|
|
163
|
+
|
|
164
|
+
await self.send_raw({
|
|
165
|
+
"v": 1,
|
|
166
|
+
"type": "agent_status",
|
|
167
|
+
"id": _new_frame_id(),
|
|
168
|
+
"ts": _now_iso(),
|
|
169
|
+
"payload": payload,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
async def send_agent_session_start(
|
|
173
|
+
self,
|
|
174
|
+
agent_id: str,
|
|
175
|
+
claude_session_id: str,
|
|
176
|
+
workdir_path: str | None = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Send an ``agent_session_start`` frame."""
|
|
179
|
+
payload: dict = {"agent_id": agent_id, "claude_session_id": claude_session_id}
|
|
180
|
+
if workdir_path:
|
|
181
|
+
payload["workdir_path"] = workdir_path
|
|
182
|
+
|
|
183
|
+
await self.send_raw({
|
|
184
|
+
"v": 1,
|
|
185
|
+
"type": "agent_session_start",
|
|
186
|
+
"id": _new_frame_id(),
|
|
187
|
+
"ts": _now_iso(),
|
|
188
|
+
"payload": payload,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
async def send_tool_call(
|
|
192
|
+
self,
|
|
193
|
+
agent_id: str,
|
|
194
|
+
session_id: str | None,
|
|
195
|
+
tool_name: str,
|
|
196
|
+
args: dict,
|
|
197
|
+
*,
|
|
198
|
+
timeout: float | None = None,
|
|
199
|
+
) -> dict:
|
|
200
|
+
"""Send a ``tool_call`` frame and await the matching ``tool_result``.
|
|
201
|
+
|
|
202
|
+
Returns the tool_result payload dict on success.
|
|
203
|
+
On timeout returns a synthetic error::
|
|
204
|
+
|
|
205
|
+
{"request_id": rid, "ok": False, "error": {"code": "SERVER_TIMEOUT"}}
|
|
206
|
+
"""
|
|
207
|
+
effective_timeout = timeout if timeout is not None else self.default_tool_call_timeout
|
|
208
|
+
rid = _new_request_id()
|
|
209
|
+
|
|
210
|
+
loop = asyncio.get_running_loop()
|
|
211
|
+
fut: asyncio.Future = loop.create_future()
|
|
212
|
+
self.pending_tool_calls[rid] = fut
|
|
213
|
+
|
|
214
|
+
# 1k #55 — fine-grained observability for the multi-tool wake bug.
|
|
215
|
+
# Logs at every state transition let us reconstruct timing of
|
|
216
|
+
# concurrent calls (rid is unique so log lines are unambiguous).
|
|
217
|
+
logger.info(
|
|
218
|
+
"outbound_queue: send_tool_call ENTER tool=%s agent=%s rid=%s "
|
|
219
|
+
"pending_count=%d",
|
|
220
|
+
tool_name, agent_id, rid, len(self.pending_tool_calls),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
await self.send_raw({
|
|
224
|
+
"v": 1,
|
|
225
|
+
"type": "tool_call",
|
|
226
|
+
"id": _new_frame_id(),
|
|
227
|
+
"ts": _now_iso(),
|
|
228
|
+
"payload": {
|
|
229
|
+
"request_id": rid,
|
|
230
|
+
"agent_id": agent_id,
|
|
231
|
+
"session_id": session_id,
|
|
232
|
+
"tool_name": tool_name,
|
|
233
|
+
"args": args,
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
logger.info(
|
|
237
|
+
"outbound_queue: send_tool_call SENT rid=%s — awaiting tool_result",
|
|
238
|
+
rid,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
result = await asyncio.wait_for(fut, timeout=effective_timeout)
|
|
243
|
+
self.pending_tool_calls.pop(rid, None)
|
|
244
|
+
logger.info(
|
|
245
|
+
"outbound_queue: send_tool_call RESOLVED rid=%s ok=%s "
|
|
246
|
+
"pending_count=%d",
|
|
247
|
+
rid, result.get("ok"), len(self.pending_tool_calls),
|
|
248
|
+
)
|
|
249
|
+
return result
|
|
250
|
+
except asyncio.TimeoutError:
|
|
251
|
+
self.pending_tool_calls.pop(rid, None)
|
|
252
|
+
logger.warning(
|
|
253
|
+
"outbound_queue: send_tool_call TIMEOUT rid=%s after %.1fs "
|
|
254
|
+
"pending_count=%d",
|
|
255
|
+
rid, effective_timeout, len(self.pending_tool_calls),
|
|
256
|
+
)
|
|
257
|
+
return {
|
|
258
|
+
"request_id": rid,
|
|
259
|
+
"ok": False,
|
|
260
|
+
"error": {"code": "SERVER_TIMEOUT"},
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async def send_raw(self, frame: dict) -> None:
|
|
264
|
+
"""Lock-protected raw send. All other helpers call this."""
|
|
265
|
+
async with self._send_lock:
|
|
266
|
+
if self.transport is None:
|
|
267
|
+
# Daemon constructed the outbound queue but is not yet
|
|
268
|
+
# connected (or just disconnected). Surface a clear error
|
|
269
|
+
# rather than ``AttributeError`` on .send.
|
|
270
|
+
raise RuntimeError("OutboundQueue.transport is not bound")
|
|
271
|
+
await self.transport.send_frame(frame)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class _LegacyWSAdapter:
|
|
275
|
+
"""Adapt a WSClient (or test fake with ``async send(frame)``) to Transport.
|
|
276
|
+
|
|
277
|
+
This keeps the unit tests in ``test_outbound_queue.py`` and the legacy
|
|
278
|
+
``main_loop`` WS path running while new code uses Transport directly.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
def __init__(self, ws: object) -> None:
|
|
282
|
+
self._ws = ws
|
|
283
|
+
|
|
284
|
+
async def send_frame(self, frame: dict) -> None:
|
|
285
|
+
await self._ws.send(frame) # type: ignore[attr-defined]
|
|
286
|
+
|
|
287
|
+
async def close(self) -> None:
|
|
288
|
+
# Lifecycle owned elsewhere — see ``WSTransport.close`` rationale.
|
|
289
|
+
return
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Per-profile path resolution for the cloudagent daemon.
|
|
2
|
+
|
|
3
|
+
A *profile* lets one host nurse multiple cloudagent backends in parallel
|
|
4
|
+
(prod, fr, staging, …) by namespacing every persistent resource the
|
|
5
|
+
daemon owns. The ``"default"`` profile keeps the legacy paths
|
|
6
|
+
byte-for-byte unchanged — that is the foundation of our zero-migration
|
|
7
|
+
guarantee for users already running the daemon.
|
|
8
|
+
|
|
9
|
+
Layout (M6 multi-profile proposal 2026-05-18):
|
|
10
|
+
|
|
11
|
+
default profile named profile "fr"
|
|
12
|
+
───────────────── ─────────────────
|
|
13
|
+
~/.cloudagent/daemon/ ~/.cloudagent/daemon/profiles/fr/
|
|
14
|
+
├── config.json ├── config.json
|
|
15
|
+
├── state.json ├── state.json
|
|
16
|
+
├── bridge.sock ├── bridge.sock
|
|
17
|
+
├── daemon.lock ├── daemon.lock
|
|
18
|
+
└── logs/ └── logs/
|
|
19
|
+
|
|
20
|
+
~/.cloudagent/agents/ ~/.cloudagent/agents-fr/
|
|
21
|
+
|
|
22
|
+
launchd label: launchd label:
|
|
23
|
+
chat.cloudagent.daemon chat.cloudagent.daemon.fr
|
|
24
|
+
systemd unit: systemd unit:
|
|
25
|
+
cloudagent-daemon.service cloudagent-daemon-fr.service
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
DEFAULT_PROFILE = "default"
|
|
34
|
+
|
|
35
|
+
# kebab-case, first char alnum, total length 1..31. Disallow ``.``, ``/``,
|
|
36
|
+
# ``..``, whitespace, uppercase, underscores. Constraints come from:
|
|
37
|
+
# - launchd label segments (RFC-1035-ish, no dots / colons in the suffix)
|
|
38
|
+
# - filesystem safety (no path traversal)
|
|
39
|
+
# - cross-platform sanity (no case-sensitivity surprises on macOS HFS+)
|
|
40
|
+
_PROFILE_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,30}$")
|
|
41
|
+
|
|
42
|
+
# Env var name daemon spawns surface to subprocesses (mcp_bridge, future
|
|
43
|
+
# helpers). Keeps the active profile pinned across exec boundaries even
|
|
44
|
+
# when the parent never gets a chance to pass an explicit ``--profile``
|
|
45
|
+
# flag. ``cli.py`` reads it as fallback for ``agent register`` /
|
|
46
|
+
# ``agent unregister`` etc.
|
|
47
|
+
ENV_VAR = "CLOUDAGENT_DAEMON_PROFILE"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_profile_name(name: str) -> str:
|
|
51
|
+
"""Return *name* if it is safe to use; raise ``ValueError`` otherwise.
|
|
52
|
+
|
|
53
|
+
``"default"`` is reserved and always valid (treated as the legacy /
|
|
54
|
+
unnamed profile). Anything else must match ``[a-z0-9][a-z0-9-]{0,30}``.
|
|
55
|
+
"""
|
|
56
|
+
if name == DEFAULT_PROFILE:
|
|
57
|
+
return name
|
|
58
|
+
if not isinstance(name, str) or not _PROFILE_NAME_RE.fullmatch(name):
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"invalid profile name: {name!r} — must match "
|
|
61
|
+
f"[a-z0-9][a-z0-9-]{{0,30}} (e.g. 'fr', 'prod', 'staging-eu')"
|
|
62
|
+
)
|
|
63
|
+
return name
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_profile(name: str | None) -> str:
|
|
67
|
+
"""Resolve the effective profile name.
|
|
68
|
+
|
|
69
|
+
Order: explicit *name* > ``CLOUDAGENT_DAEMON_PROFILE`` env > default.
|
|
70
|
+
Always validated. Empty string / ``None`` collapse to default.
|
|
71
|
+
"""
|
|
72
|
+
if name:
|
|
73
|
+
return validate_profile_name(name)
|
|
74
|
+
env = os.environ.get(ENV_VAR, "").strip()
|
|
75
|
+
if env:
|
|
76
|
+
return validate_profile_name(env)
|
|
77
|
+
return DEFAULT_PROFILE
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------- Filesystem layout ----------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _home() -> Path:
|
|
84
|
+
"""Return ``$HOME``. Indirection so tests can monkeypatch ``HOME``."""
|
|
85
|
+
return Path.home()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def profile_base_dir(profile: str | None = None) -> Path:
|
|
89
|
+
"""Per-profile root: ``~/.cloudagent/daemon`` (default) or
|
|
90
|
+
``~/.cloudagent/daemon/profiles/<name>`` (named)."""
|
|
91
|
+
p = resolve_profile(profile)
|
|
92
|
+
base = _home() / ".cloudagent" / "daemon"
|
|
93
|
+
if p == DEFAULT_PROFILE:
|
|
94
|
+
return base
|
|
95
|
+
return base / "profiles" / p
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def profile_config_path(profile: str | None = None) -> Path:
|
|
99
|
+
return profile_base_dir(profile) / "config.json"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def profile_state_path(profile: str | None = None) -> Path:
|
|
103
|
+
return profile_base_dir(profile) / "state.json"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def profile_socket_path(profile: str | None = None) -> Path:
|
|
107
|
+
return profile_base_dir(profile) / "bridge.sock"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def profile_lock_path(profile: str | None = None) -> Path:
|
|
111
|
+
return profile_base_dir(profile) / "daemon.lock"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def profile_pid_path(profile: str | None = None) -> Path:
|
|
115
|
+
return profile_base_dir(profile) / "daemon.pid"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def profile_log_dir(profile: str | None = None) -> Path:
|
|
119
|
+
return profile_base_dir(profile) / "logs"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def profile_workspace_root(profile: str | None = None) -> Path:
|
|
123
|
+
"""Default agent workspace root.
|
|
124
|
+
|
|
125
|
+
The default profile keeps the legacy ``~/.cloudagent/agents``;
|
|
126
|
+
named profiles get a sibling ``~/.cloudagent/agents-<name>`` so
|
|
127
|
+
file managers / terminals visually group them next to the
|
|
128
|
+
original tree rather than burying them under another nesting
|
|
129
|
+
level. Callers who want a fully-custom path keep using
|
|
130
|
+
``--workspace-root`` exactly as before.
|
|
131
|
+
"""
|
|
132
|
+
p = resolve_profile(profile)
|
|
133
|
+
if p == DEFAULT_PROFILE:
|
|
134
|
+
return _home() / ".cloudagent" / "agents"
|
|
135
|
+
return _home() / ".cloudagent" / f"agents-{p}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------- Service identifiers ----------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def profile_launchd_label(profile: str | None = None) -> str:
|
|
142
|
+
"""launchd Label. Default profile keeps the legacy ``chat.cloudagent.daemon``;
|
|
143
|
+
named profiles append ``.<name>`` so multiple plists coexist."""
|
|
144
|
+
p = resolve_profile(profile)
|
|
145
|
+
base = "chat.cloudagent.daemon"
|
|
146
|
+
return base if p == DEFAULT_PROFILE else f"{base}.{p}"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def profile_systemd_unit_name(profile: str | None = None) -> str:
|
|
150
|
+
"""systemd unit filename. Default keeps legacy ``cloudagent-daemon.service``;
|
|
151
|
+
named profiles use a literal ``cloudagent-daemon-<name>.service``."""
|
|
152
|
+
p = resolve_profile(profile)
|
|
153
|
+
return (
|
|
154
|
+
"cloudagent-daemon.service"
|
|
155
|
+
if p == DEFAULT_PROFILE
|
|
156
|
+
else f"cloudagent-daemon-{p}.service"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------- Discovery ----------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def list_installed_profiles() -> list[str]:
|
|
164
|
+
"""Enumerate profiles that have a ``config.json`` on disk.
|
|
165
|
+
|
|
166
|
+
Default is included whenever its config exists; named profiles
|
|
167
|
+
show up under ``~/.cloudagent/daemon/profiles/<name>/``. Returns
|
|
168
|
+
a sorted list with ``default`` first (when present) so output
|
|
169
|
+
order is stable for ``list-profiles``.
|
|
170
|
+
"""
|
|
171
|
+
found: list[str] = []
|
|
172
|
+
if profile_config_path(DEFAULT_PROFILE).exists():
|
|
173
|
+
found.append(DEFAULT_PROFILE)
|
|
174
|
+
profiles_dir = _home() / ".cloudagent" / "daemon" / "profiles"
|
|
175
|
+
if profiles_dir.is_dir():
|
|
176
|
+
for child in sorted(profiles_dir.iterdir()):
|
|
177
|
+
if not child.is_dir():
|
|
178
|
+
continue
|
|
179
|
+
try:
|
|
180
|
+
validate_profile_name(child.name)
|
|
181
|
+
except ValueError:
|
|
182
|
+
continue
|
|
183
|
+
if (child / "config.json").exists():
|
|
184
|
+
found.append(child.name)
|
|
185
|
+
return found
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
__all__ = [
|
|
189
|
+
"DEFAULT_PROFILE",
|
|
190
|
+
"ENV_VAR",
|
|
191
|
+
"validate_profile_name",
|
|
192
|
+
"resolve_profile",
|
|
193
|
+
"profile_base_dir",
|
|
194
|
+
"profile_config_path",
|
|
195
|
+
"profile_state_path",
|
|
196
|
+
"profile_socket_path",
|
|
197
|
+
"profile_lock_path",
|
|
198
|
+
"profile_pid_path",
|
|
199
|
+
"profile_log_dir",
|
|
200
|
+
"profile_workspace_root",
|
|
201
|
+
"profile_launchd_label",
|
|
202
|
+
"profile_systemd_unit_name",
|
|
203
|
+
"list_installed_profiles",
|
|
204
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""WebSocket frame schemas — vendored from backend/src/cloudagent/services/daemon_protocol.py.
|
|
2
|
+
|
|
3
|
+
Spec: docs/superpowers/specs/2026-05-08-local-daemon-runtime-design.md §4
|
|
4
|
+
|
|
5
|
+
Vendored (not imported from backend) so daemon_cli can be installed
|
|
6
|
+
independently. If a schema changes on the backend, update this file
|
|
7
|
+
in lock-step. A bigger Phase 2 refactor moves these to a shared package.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
# ---- Payload models ----
|
|
17
|
+
|
|
18
|
+
class HelloAgentEntry(BaseModel):
|
|
19
|
+
agent_id: str
|
|
20
|
+
claude_session_id: str | None = None
|
|
21
|
+
runtime_config: dict[str, Any] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class HelloPayload(BaseModel):
|
|
25
|
+
host_id: str
|
|
26
|
+
agents: list[HelloAgentEntry]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ReadyPayload(BaseModel):
|
|
30
|
+
daemon_ver: str
|
|
31
|
+
runtimes: list[dict[str, Any]]
|
|
32
|
+
hostname: str
|
|
33
|
+
os: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AssignPayload(BaseModel):
|
|
37
|
+
agent_id: str
|
|
38
|
+
claude_session_id: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UnassignPayload(BaseModel):
|
|
42
|
+
agent_id: str
|
|
43
|
+
reason: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DeliverPayload(BaseModel):
|
|
47
|
+
agent_id: str
|
|
48
|
+
session_id: str
|
|
49
|
+
message_id: str
|
|
50
|
+
kind: str
|
|
51
|
+
body: str
|
|
52
|
+
sender: str
|
|
53
|
+
target: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DeliveryAckPayload(BaseModel):
|
|
57
|
+
seq: int
|
|
58
|
+
message_id: str
|
|
59
|
+
status: Literal["received", "started", "failed", "queue_full"]
|
|
60
|
+
retry_after_ms: int | None = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ToolCallPayload(BaseModel):
|
|
64
|
+
request_id: str
|
|
65
|
+
agent_id: str
|
|
66
|
+
session_id: str | None = None
|
|
67
|
+
tool_name: str
|
|
68
|
+
args: dict[str, Any]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ToolResultPayload(BaseModel):
|
|
72
|
+
request_id: str
|
|
73
|
+
ok: bool
|
|
74
|
+
data: dict[str, Any] | None = None
|
|
75
|
+
error: dict[str, Any] | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AgentSessionStartPayload(BaseModel):
|
|
79
|
+
agent_id: str
|
|
80
|
+
claude_session_id: str
|
|
81
|
+
workdir_path: str | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class AgentStatusPayload(BaseModel):
|
|
85
|
+
agent_id: str
|
|
86
|
+
status: Literal["running", "idle", "crashed", "quarantined", "backpressure_dropped"]
|
|
87
|
+
exit_code: int | None = None
|
|
88
|
+
error: str | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PingPayload(BaseModel):
|
|
92
|
+
pass # empty payload
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class PongPayload(BaseModel):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ByePayload(BaseModel):
|
|
100
|
+
reason: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---- Frame envelope ----
|
|
104
|
+
|
|
105
|
+
FrameType = Literal[
|
|
106
|
+
"hello", "ready", "assign", "unassign",
|
|
107
|
+
"deliver", "delivery_ack",
|
|
108
|
+
"tool_call", "tool_result",
|
|
109
|
+
"agent_session_start", "agent_status",
|
|
110
|
+
"ping", "pong", "bye",
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
_PAYLOAD_TYPES = {
|
|
114
|
+
"hello": HelloPayload, "ready": ReadyPayload,
|
|
115
|
+
"assign": AssignPayload, "unassign": UnassignPayload,
|
|
116
|
+
"deliver": DeliverPayload, "delivery_ack": DeliveryAckPayload,
|
|
117
|
+
"tool_call": ToolCallPayload, "tool_result": ToolResultPayload,
|
|
118
|
+
"agent_session_start": AgentSessionStartPayload,
|
|
119
|
+
"agent_status": AgentStatusPayload,
|
|
120
|
+
"ping": PingPayload, "pong": PongPayload, "bye": ByePayload,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Frame(BaseModel):
|
|
125
|
+
v: Literal[1]
|
|
126
|
+
type: FrameType
|
|
127
|
+
id: str
|
|
128
|
+
ts: datetime
|
|
129
|
+
seq: int | None = None
|
|
130
|
+
payload: Any # validated post-init
|
|
131
|
+
|
|
132
|
+
def model_post_init(self, _ctx: Any) -> None:
|
|
133
|
+
cls = _PAYLOAD_TYPES.get(self.type)
|
|
134
|
+
if cls is None:
|
|
135
|
+
raise ValueError(f"unknown frame type: {self.type}")
|
|
136
|
+
if not isinstance(self.payload, cls):
|
|
137
|
+
self.payload = cls.model_validate(self.payload)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def parse_frame(raw: dict[str, Any]) -> Frame:
|
|
141
|
+
return Frame.model_validate(raw)
|