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
cloudagent_daemon/cli.py
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 1i Bug D — daemon connects directly to its WAN backend; macOS / corporate
|
|
15
|
+
# system-wide SOCKS / HTTP proxy settings would otherwise be inherited via
|
|
16
|
+
# urllib defaults and cause WSClient to fail with
|
|
17
|
+
# "connecting through a SOCKS proxy requires python-socks" when the user
|
|
18
|
+
# has macOS System Preferences → Network → Proxies enabled.
|
|
19
|
+
#
|
|
20
|
+
# Users who genuinely need to route the daemon through a corporate proxy
|
|
21
|
+
# can opt back in via env vars BEFORE invoking the CLI; this helper only
|
|
22
|
+
# applies the bypass when no override is already present.
|
|
23
|
+
_DEFAULT_NO_PROXY = "*"
|
|
24
|
+
_PROXY_VARS = (
|
|
25
|
+
"HTTP_PROXY",
|
|
26
|
+
"HTTPS_PROXY",
|
|
27
|
+
"ALL_PROXY",
|
|
28
|
+
"http_proxy",
|
|
29
|
+
"https_proxy",
|
|
30
|
+
"all_proxy",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _bypass_system_proxy_by_default() -> None:
|
|
35
|
+
"""Default the daemon to NO proxy. Idempotent — safe to call multiple times."""
|
|
36
|
+
os.environ.setdefault("NO_PROXY", _DEFAULT_NO_PROXY)
|
|
37
|
+
os.environ.setdefault("no_proxy", _DEFAULT_NO_PROXY)
|
|
38
|
+
for var in _PROXY_VARS:
|
|
39
|
+
# Only clear vars; honour an explicit user opt-in by checking
|
|
40
|
+
# CLOUDAGENT_USE_SYSTEM_PROXY=1 first.
|
|
41
|
+
if os.environ.get("CLOUDAGENT_USE_SYSTEM_PROXY") == "1":
|
|
42
|
+
return
|
|
43
|
+
if var in os.environ:
|
|
44
|
+
del os.environ[var]
|
|
45
|
+
|
|
46
|
+
app = typer.Typer(
|
|
47
|
+
name="cloudagent-daemon",
|
|
48
|
+
help="Local daemon for cloudagent — runs on user's machine and bridges claude subprocesses to the cloudagent backend.",
|
|
49
|
+
no_args_is_help=True,
|
|
50
|
+
)
|
|
51
|
+
agent_app = typer.Typer(no_args_is_help=True, help="Manage local agent workspaces.")
|
|
52
|
+
app.add_typer(agent_app, name="agent")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# M6 multi-profile (2026-05-18) — every stateful command takes
|
|
56
|
+
# ``--profile <name>``; default ``"default"`` is byte-for-byte identical
|
|
57
|
+
# to the pre-multi-profile behaviour. ``CLOUDAGENT_DAEMON_PROFILE`` env
|
|
58
|
+
# is the cross-process fallback (set in plist/unit so spawned children
|
|
59
|
+
# inherit the active profile without an explicit flag).
|
|
60
|
+
_PROFILE_HELP = (
|
|
61
|
+
"Profile name (default 'default'). Run multiple daemons against "
|
|
62
|
+
"different backends by giving each a unique profile."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_cli_profile(profile: str | None) -> str:
|
|
67
|
+
"""Validate + resolve a CLI ``--profile`` value with friendly errors."""
|
|
68
|
+
from .paths import resolve_profile
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
return resolve_profile(profile)
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
74
|
+
raise typer.Exit(2)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command()
|
|
78
|
+
def login(
|
|
79
|
+
server: str = typer.Option(..., "--server", help="Backend server URL."),
|
|
80
|
+
api_key: str = typer.Option(
|
|
81
|
+
...,
|
|
82
|
+
"--api-key",
|
|
83
|
+
prompt=True,
|
|
84
|
+
hide_input=True,
|
|
85
|
+
help="Machine API key (sk_machine_***).",
|
|
86
|
+
),
|
|
87
|
+
insecure: bool = typer.Option(
|
|
88
|
+
False,
|
|
89
|
+
"--insecure",
|
|
90
|
+
help="Skip TLS certificate verification (for self-signed servers).",
|
|
91
|
+
),
|
|
92
|
+
profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Authorize this machine and store sk_machine_*** locally."""
|
|
95
|
+
_bypass_system_proxy_by_default()
|
|
96
|
+
from .login import normalize_server_url, verify_and_capture_host_id
|
|
97
|
+
from .config import DaemonConfig
|
|
98
|
+
|
|
99
|
+
profile_name = _resolve_cli_profile(profile)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
server = normalize_server_url(server)
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
105
|
+
raise typer.Exit(2)
|
|
106
|
+
|
|
107
|
+
if not api_key.startswith("sk_machine_"):
|
|
108
|
+
typer.echo("Error: api_key must start with sk_machine_", err=True)
|
|
109
|
+
raise typer.Exit(2)
|
|
110
|
+
|
|
111
|
+
if insecure:
|
|
112
|
+
typer.secho(
|
|
113
|
+
"⚠️ Insecure: TLS certificate verification disabled.",
|
|
114
|
+
fg=typer.colors.YELLOW,
|
|
115
|
+
err=True,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
host_id = asyncio.run(
|
|
120
|
+
verify_and_capture_host_id(server, api_key, insecure=insecure)
|
|
121
|
+
)
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
typer.echo(f"Login failed: {exc}", err=True)
|
|
124
|
+
raise typer.Exit(1)
|
|
125
|
+
|
|
126
|
+
cfg = DaemonConfig(server_url=server, machine_api_key=api_key, host_id=host_id)
|
|
127
|
+
cfg.save_default(profile_name)
|
|
128
|
+
typer.echo(
|
|
129
|
+
f"Logged in. host_id={host_id} profile={profile_name}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command()
|
|
134
|
+
def start(
|
|
135
|
+
server_url: str | None = typer.Option(
|
|
136
|
+
None,
|
|
137
|
+
"--server-url",
|
|
138
|
+
help="Backend URL for one-shot mode (pair with --api-key; bypasses config.json).",
|
|
139
|
+
),
|
|
140
|
+
api_key: str | None = typer.Option(
|
|
141
|
+
None,
|
|
142
|
+
"--api-key",
|
|
143
|
+
help="Machine API key for one-shot mode (pair with --server-url).",
|
|
144
|
+
),
|
|
145
|
+
insecure: bool = typer.Option(
|
|
146
|
+
False,
|
|
147
|
+
"--insecure",
|
|
148
|
+
help="Skip TLS certificate verification (for self-signed servers).",
|
|
149
|
+
),
|
|
150
|
+
transport: str = typer.Option(
|
|
151
|
+
"auto",
|
|
152
|
+
"--transport",
|
|
153
|
+
help=(
|
|
154
|
+
"Transport: 'sse' (SSE+POST, default), 'ws' (legacy WebSocket), "
|
|
155
|
+
"or 'auto' (alias for 'sse' in v0.2.0)."
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
workspace_root: Path | None = typer.Option(
|
|
159
|
+
None,
|
|
160
|
+
"--workspace-root",
|
|
161
|
+
"-w",
|
|
162
|
+
help=(
|
|
163
|
+
"Root directory for all agent workspaces. Default: "
|
|
164
|
+
"~/.cloudagent/agents/ (or agents-<profile>/ for named "
|
|
165
|
+
"profiles). The chosen path is persisted to state.json "
|
|
166
|
+
"so subsequent runs without the flag reuse it."
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Connect to the server and run the daemon main loop.
|
|
172
|
+
|
|
173
|
+
Default: SSE+POST transport (works behind Clash/proxy/middleboxes).
|
|
174
|
+
Pass ``--transport ws`` to use the legacy WebSocket path.
|
|
175
|
+
|
|
176
|
+
Default config: loads ``~/.cloudagent/daemon/config.json`` (run
|
|
177
|
+
``login`` first). One-shot: pass ``--server-url`` + ``--api-key``
|
|
178
|
+
together to run without persisting any config — slock-equivalent
|
|
179
|
+
UX for ephemeral / CI use.
|
|
180
|
+
"""
|
|
181
|
+
_bypass_system_proxy_by_default()
|
|
182
|
+
from .config import DaemonConfig, DaemonState
|
|
183
|
+
from .login import normalize_server_url, verify_and_capture_host_id
|
|
184
|
+
from .main_loop import run_daemon
|
|
185
|
+
from .runtime_detect import detect_runtimes
|
|
186
|
+
|
|
187
|
+
profile_name = _resolve_cli_profile(profile)
|
|
188
|
+
|
|
189
|
+
logging.basicConfig(
|
|
190
|
+
level=logging.INFO,
|
|
191
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
192
|
+
)
|
|
193
|
+
# Wave C #3 — opt-in JSONL formatter (env-gated; default off
|
|
194
|
+
# preserves grep workflow over launchd.err.log).
|
|
195
|
+
from .jsonl_logging import configure_logging as _configure_jsonl_logging
|
|
196
|
+
_configure_jsonl_logging()
|
|
197
|
+
|
|
198
|
+
transport = (transport or "auto").lower()
|
|
199
|
+
if transport not in ("auto", "sse", "ws"):
|
|
200
|
+
typer.echo(
|
|
201
|
+
f"Error: --transport must be 'auto', 'sse', or 'ws' (got {transport!r})",
|
|
202
|
+
err=True,
|
|
203
|
+
)
|
|
204
|
+
raise typer.Exit(2)
|
|
205
|
+
|
|
206
|
+
if insecure:
|
|
207
|
+
typer.secho(
|
|
208
|
+
"⚠️ Insecure: TLS certificate verification disabled.",
|
|
209
|
+
fg=typer.colors.YELLOW,
|
|
210
|
+
err=True,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if server_url is not None and api_key is not None:
|
|
214
|
+
# One-shot mode: skip config persistence entirely.
|
|
215
|
+
if not api_key.startswith("sk_machine_"):
|
|
216
|
+
typer.echo(
|
|
217
|
+
"Error: --api-key must start with sk_machine_",
|
|
218
|
+
err=True,
|
|
219
|
+
)
|
|
220
|
+
raise typer.Exit(2)
|
|
221
|
+
try:
|
|
222
|
+
server_url = normalize_server_url(server_url)
|
|
223
|
+
except ValueError as exc:
|
|
224
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
225
|
+
raise typer.Exit(2)
|
|
226
|
+
try:
|
|
227
|
+
host_id = asyncio.run(
|
|
228
|
+
verify_and_capture_host_id(
|
|
229
|
+
server_url, api_key, insecure=insecure
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
typer.echo(
|
|
234
|
+
f"Failed to verify key with server: {exc}", err=True
|
|
235
|
+
)
|
|
236
|
+
raise typer.Exit(1)
|
|
237
|
+
cfg = DaemonConfig(
|
|
238
|
+
server_url=server_url,
|
|
239
|
+
machine_api_key=api_key,
|
|
240
|
+
host_id=host_id,
|
|
241
|
+
)
|
|
242
|
+
elif server_url is not None or api_key is not None:
|
|
243
|
+
typer.echo(
|
|
244
|
+
"Error: --server-url and --api-key must be used together",
|
|
245
|
+
err=True,
|
|
246
|
+
)
|
|
247
|
+
raise typer.Exit(2)
|
|
248
|
+
else:
|
|
249
|
+
try:
|
|
250
|
+
cfg = DaemonConfig.load_default(profile_name)
|
|
251
|
+
except FileNotFoundError:
|
|
252
|
+
from .paths import DEFAULT_PROFILE
|
|
253
|
+
|
|
254
|
+
extra = (
|
|
255
|
+
""
|
|
256
|
+
if profile_name == DEFAULT_PROFILE
|
|
257
|
+
else f" --profile {profile_name}"
|
|
258
|
+
)
|
|
259
|
+
typer.echo(
|
|
260
|
+
f"Error: not logged in for profile {profile_name!r}. "
|
|
261
|
+
f"Run `cloudagent-daemon login{extra} --server ... "
|
|
262
|
+
f"--api-key sk_machine_...` first, or pass --server-url "
|
|
263
|
+
f"+ --api-key for one-shot mode.",
|
|
264
|
+
err=True,
|
|
265
|
+
)
|
|
266
|
+
raise typer.Exit(1)
|
|
267
|
+
|
|
268
|
+
state = (
|
|
269
|
+
DaemonState.load_default(profile_name)
|
|
270
|
+
if DaemonState.default_path(profile_name).exists()
|
|
271
|
+
else DaemonState()
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Task #113 (2026-05-12) — resolve --workspace-root, with fallback
|
|
275
|
+
# to whatever was persisted on the last start. If the user passes
|
|
276
|
+
# the flag we tilde-expand + resolve to abspath + mkdir(parents=
|
|
277
|
+
# True, exist_ok=True) and persist it back to state.json so the
|
|
278
|
+
# next launchctl restart picks it up without re-passing the flag.
|
|
279
|
+
# If the user doesn't pass the flag we honour the persisted
|
|
280
|
+
# value; if state.json doesn't have one either we leave it None
|
|
281
|
+
# and ``Workspace`` falls back to its compiled-in default.
|
|
282
|
+
if workspace_root is not None:
|
|
283
|
+
workspace_root = Path(
|
|
284
|
+
os.path.expanduser(str(workspace_root))
|
|
285
|
+
).resolve()
|
|
286
|
+
workspace_root.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
state.workspace_root = str(workspace_root)
|
|
288
|
+
state.save_default(profile_name)
|
|
289
|
+
elif state.workspace_root:
|
|
290
|
+
workspace_root = Path(state.workspace_root)
|
|
291
|
+
|
|
292
|
+
# Print detected runtimes for user feedback (slock-equivalent UX).
|
|
293
|
+
runtimes = detect_runtimes()
|
|
294
|
+
if runtimes:
|
|
295
|
+
typer.echo("Detected runtimes:")
|
|
296
|
+
for r in runtimes:
|
|
297
|
+
typer.echo(f" ✓ {r.name} {r.version} ({r.binary})")
|
|
298
|
+
else:
|
|
299
|
+
typer.echo("No AI coding runtimes detected on PATH.")
|
|
300
|
+
from .paths import DEFAULT_PROFILE as _DEFAULT_PROFILE
|
|
301
|
+
|
|
302
|
+
profile_suffix = (
|
|
303
|
+
""
|
|
304
|
+
if profile_name == _DEFAULT_PROFILE
|
|
305
|
+
else f" [profile={profile_name}]"
|
|
306
|
+
)
|
|
307
|
+
typer.echo(
|
|
308
|
+
f"Connecting to {cfg.server_url} as host {cfg.host_id}{profile_suffix}..."
|
|
309
|
+
)
|
|
310
|
+
# Belt-and-suspenders flush — the plist/systemd unit also sets
|
|
311
|
+
# PYTHONUNBUFFERED=1, but if a future deploy ships without that env
|
|
312
|
+
# var the detect lines would otherwise sit in stdout's 8 KB block
|
|
313
|
+
# buffer forever (long-running process never exits to flush). Users
|
|
314
|
+
# tailing launchd.out.log would see stale or empty output and assume
|
|
315
|
+
# detection failed (incident 2026-05-11).
|
|
316
|
+
sys.stdout.flush()
|
|
317
|
+
sys.stderr.flush()
|
|
318
|
+
|
|
319
|
+
# Test/override hooks via environment variables (used by E2E tests to
|
|
320
|
+
# inject a fake claude binary and a short-path unix socket).
|
|
321
|
+
claude_binary: str = os.environ.get("CLOUDAGENT_DAEMON_CLAUDE_BINARY", "claude")
|
|
322
|
+
socket_path_override: Path | None = (
|
|
323
|
+
Path(os.environ["CLOUDAGENT_DAEMON_SOCKET_PATH"])
|
|
324
|
+
if "CLOUDAGENT_DAEMON_SOCKET_PATH" in os.environ
|
|
325
|
+
else None
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
from .paths import profile_pid_path, profile_lock_path
|
|
329
|
+
|
|
330
|
+
pid_path = profile_pid_path(profile_name)
|
|
331
|
+
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
|
|
333
|
+
# M6 multi-profile (proposal §3.5) — best-effort exclusive lock so
|
|
334
|
+
# ``cloudagent-daemon start --profile fr`` rejects fast when launchd
|
|
335
|
+
# is already running an fr daemon. fcntl.flock is advisory + per-
|
|
336
|
+
# process-table; fine for this single-user, single-host scope.
|
|
337
|
+
lock_path = profile_lock_path(profile_name)
|
|
338
|
+
lock_fd: int | None = None
|
|
339
|
+
try:
|
|
340
|
+
import fcntl
|
|
341
|
+
|
|
342
|
+
lock_fd = os.open(
|
|
343
|
+
str(lock_path), os.O_RDWR | os.O_CREAT | os.O_CLOEXEC, 0o600
|
|
344
|
+
)
|
|
345
|
+
try:
|
|
346
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
347
|
+
except OSError:
|
|
348
|
+
# Surface the holder's PID if recoverable.
|
|
349
|
+
existing_pid: str = ""
|
|
350
|
+
if pid_path.exists():
|
|
351
|
+
try:
|
|
352
|
+
existing_pid = pid_path.read_text().strip()
|
|
353
|
+
except OSError:
|
|
354
|
+
existing_pid = ""
|
|
355
|
+
os.close(lock_fd)
|
|
356
|
+
lock_fd = None
|
|
357
|
+
hint = (
|
|
358
|
+
f"running pid={existing_pid}"
|
|
359
|
+
if existing_pid
|
|
360
|
+
else "another process holds the lock"
|
|
361
|
+
)
|
|
362
|
+
typer.echo(
|
|
363
|
+
f"Error: profile {profile_name!r} already has a daemon "
|
|
364
|
+
f"running ({hint}). Stop it via "
|
|
365
|
+
f"`cloudagent-daemon stop "
|
|
366
|
+
f"{'' if profile_name == _DEFAULT_PROFILE else f'--profile {profile_name} '}`"
|
|
367
|
+
f"or `uninstall-service "
|
|
368
|
+
f"{'' if profile_name == _DEFAULT_PROFILE else f'--profile {profile_name}'}`"
|
|
369
|
+
f" before starting another.",
|
|
370
|
+
err=True,
|
|
371
|
+
)
|
|
372
|
+
raise typer.Exit(1)
|
|
373
|
+
except ImportError:
|
|
374
|
+
# fcntl missing (extremely rare on Unix; guards against weird
|
|
375
|
+
# static-builds). Skip locking rather than crash — single-host
|
|
376
|
+
# multi-profile is best-effort safety, not a hard invariant.
|
|
377
|
+
lock_fd = None
|
|
378
|
+
|
|
379
|
+
pid_path.write_text(str(os.getpid()))
|
|
380
|
+
|
|
381
|
+
# Pin profile in env so any subprocess we spawn (mcp_bridge, claude
|
|
382
|
+
# children) inherits it without needing an explicit --profile flag.
|
|
383
|
+
from .paths import ENV_VAR as _PROFILE_ENV_VAR
|
|
384
|
+
|
|
385
|
+
os.environ[_PROFILE_ENV_VAR] = profile_name
|
|
386
|
+
|
|
387
|
+
def _install_sigterm_handler(loop: asyncio.AbstractEventLoop) -> None:
|
|
388
|
+
"""Convert SIGTERM into a graceful asyncio cancellation.
|
|
389
|
+
|
|
390
|
+
Without this, SIGTERM kills the process immediately (SIG_DFL) so the
|
|
391
|
+
``finally: pid_path.unlink()`` block never runs. We install a handler
|
|
392
|
+
that cancels the main daemon task, letting asyncio unwind cleanly.
|
|
393
|
+
"""
|
|
394
|
+
main_task = asyncio.current_task()
|
|
395
|
+
if main_task is None:
|
|
396
|
+
return # pragma: no cover — shouldn't happen inside asyncio.run
|
|
397
|
+
loop.remove_signal_handler(signal.SIGTERM)
|
|
398
|
+
loop.add_signal_handler(
|
|
399
|
+
signal.SIGTERM,
|
|
400
|
+
lambda: main_task.cancel("SIGTERM"),
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# M6 — derive per-profile socket + state paths so non-default
|
|
404
|
+
# profiles get their own bridge.sock under
|
|
405
|
+
# ``~/.cloudagent/daemon/profiles/<name>/``. The env-var override
|
|
406
|
+
# still wins (used by E2E tests to inject a short-path socket).
|
|
407
|
+
from .paths import profile_socket_path, profile_state_path
|
|
408
|
+
|
|
409
|
+
effective_socket_path = (
|
|
410
|
+
socket_path_override
|
|
411
|
+
if socket_path_override is not None
|
|
412
|
+
else profile_socket_path(profile_name)
|
|
413
|
+
)
|
|
414
|
+
effective_state_path = profile_state_path(profile_name)
|
|
415
|
+
|
|
416
|
+
async def _start_with_sigterm() -> None:
|
|
417
|
+
_install_sigterm_handler(asyncio.get_running_loop())
|
|
418
|
+
await run_daemon(
|
|
419
|
+
cfg,
|
|
420
|
+
state,
|
|
421
|
+
claude_binary=claude_binary,
|
|
422
|
+
socket_path=effective_socket_path,
|
|
423
|
+
state_path=effective_state_path,
|
|
424
|
+
insecure=insecure,
|
|
425
|
+
transport=transport,
|
|
426
|
+
workspace_root=workspace_root,
|
|
427
|
+
profile=profile_name,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
try:
|
|
431
|
+
asyncio.run(_start_with_sigterm())
|
|
432
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
433
|
+
# Ctrl-C or SIGTERM (via cancel) are the expected shutdown paths.
|
|
434
|
+
pass
|
|
435
|
+
finally:
|
|
436
|
+
pid_path.unlink(missing_ok=True)
|
|
437
|
+
if lock_fd is not None:
|
|
438
|
+
try:
|
|
439
|
+
os.close(lock_fd)
|
|
440
|
+
except OSError:
|
|
441
|
+
pass
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@app.command()
|
|
445
|
+
def status(
|
|
446
|
+
profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Show daemon connection status and bound agents."""
|
|
449
|
+
from .config import DaemonConfig
|
|
450
|
+
from .paths import profile_pid_path
|
|
451
|
+
|
|
452
|
+
profile_name = _resolve_cli_profile(profile)
|
|
453
|
+
pid_file = profile_pid_path(profile_name)
|
|
454
|
+
if not pid_file.exists():
|
|
455
|
+
typer.echo(f"● daemon not running for profile {profile_name!r} (no PID file)")
|
|
456
|
+
raise typer.Exit(1)
|
|
457
|
+
try:
|
|
458
|
+
pid = int(pid_file.read_text().strip())
|
|
459
|
+
except (ValueError, OSError):
|
|
460
|
+
typer.echo(f"● daemon stale PID file (corrupt) profile={profile_name!r}")
|
|
461
|
+
raise typer.Exit(1)
|
|
462
|
+
if not _pid_alive(pid):
|
|
463
|
+
typer.echo(
|
|
464
|
+
f"● daemon stale PID file (pid={pid} not alive) profile={profile_name!r}"
|
|
465
|
+
)
|
|
466
|
+
raise typer.Exit(1)
|
|
467
|
+
try:
|
|
468
|
+
cfg = DaemonConfig.load_default(profile_name)
|
|
469
|
+
except FileNotFoundError:
|
|
470
|
+
typer.echo(
|
|
471
|
+
f"● daemon running (pid={pid}, profile={profile_name!r}) but no config "
|
|
472
|
+
f"— login first"
|
|
473
|
+
)
|
|
474
|
+
return
|
|
475
|
+
typer.echo(f"● daemon running (pid={pid})")
|
|
476
|
+
typer.echo(f" profile: {profile_name}")
|
|
477
|
+
typer.echo(f" server: {cfg.server_url}")
|
|
478
|
+
typer.echo(f" host_id: {cfg.host_id}")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
@app.command()
|
|
482
|
+
def doctor(
|
|
483
|
+
json_output: bool = typer.Option(
|
|
484
|
+
False,
|
|
485
|
+
"--json",
|
|
486
|
+
help="Emit the full report as a single JSON object instead of "
|
|
487
|
+
"the human-readable text rendering.",
|
|
488
|
+
),
|
|
489
|
+
profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
|
|
490
|
+
) -> None:
|
|
491
|
+
"""Run a daemon health self-check and print a status report.
|
|
492
|
+
|
|
493
|
+
Wave C #3. Reads ``~/.cloudagent/daemon/state.json``,
|
|
494
|
+
``~/.cloudagent/agents/<id>/.meta/`` files, and scrapes the running
|
|
495
|
+
daemon's ``/metrics`` unix socket (best-effort — falls back to
|
|
496
|
+
on-disk-only signals when the daemon is down).
|
|
497
|
+
|
|
498
|
+
Exit codes:
|
|
499
|
+
* 0 — all checks passed
|
|
500
|
+
* 1 — at least one signal returned ALERT (daemon down, SSE
|
|
501
|
+
silent past 180s, outbox has stuck spawns, or > 20% wake
|
|
502
|
+
failure rate)
|
|
503
|
+
"""
|
|
504
|
+
from dataclasses import asdict
|
|
505
|
+
import json as _json
|
|
506
|
+
|
|
507
|
+
from .doctor import collect_report, render_report
|
|
508
|
+
|
|
509
|
+
profile_name = _resolve_cli_profile(profile)
|
|
510
|
+
report = collect_report(profile=profile_name)
|
|
511
|
+
if json_output:
|
|
512
|
+
typer.echo(_json.dumps(asdict(report), default=str, indent=2))
|
|
513
|
+
else:
|
|
514
|
+
typer.echo(render_report(report))
|
|
515
|
+
alert = any(status == "ALERT" for _, status, _ in report.health_signals())
|
|
516
|
+
raise typer.Exit(1 if alert else 0)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@app.command()
|
|
520
|
+
def stop(
|
|
521
|
+
timeout: float = typer.Option(10.0, "--timeout", help="Seconds to wait for clean exit"),
|
|
522
|
+
profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
|
|
523
|
+
) -> None:
|
|
524
|
+
"""Gracefully stop the daemon (SIGTERM via PID file)."""
|
|
525
|
+
from .paths import profile_pid_path
|
|
526
|
+
|
|
527
|
+
profile_name = _resolve_cli_profile(profile)
|
|
528
|
+
pid_file = profile_pid_path(profile_name)
|
|
529
|
+
if not pid_file.exists():
|
|
530
|
+
typer.echo(f"daemon not running for profile {profile_name!r} (no PID file)")
|
|
531
|
+
raise typer.Exit(0)
|
|
532
|
+
try:
|
|
533
|
+
pid = int(pid_file.read_text().strip())
|
|
534
|
+
except (ValueError, OSError):
|
|
535
|
+
typer.echo("stale PID file — removing")
|
|
536
|
+
pid_file.unlink(missing_ok=True)
|
|
537
|
+
raise typer.Exit(0)
|
|
538
|
+
if not _pid_alive(pid):
|
|
539
|
+
typer.echo(f"daemon already stopped (pid={pid} not alive); cleaning up PID file")
|
|
540
|
+
pid_file.unlink(missing_ok=True)
|
|
541
|
+
raise typer.Exit(0)
|
|
542
|
+
typer.echo(f"sending SIGTERM to pid={pid}...")
|
|
543
|
+
try:
|
|
544
|
+
os.kill(pid, signal.SIGTERM)
|
|
545
|
+
except ProcessLookupError:
|
|
546
|
+
pid_file.unlink(missing_ok=True)
|
|
547
|
+
typer.echo("process already gone")
|
|
548
|
+
raise typer.Exit(0)
|
|
549
|
+
# Wait up to `timeout` seconds for clean exit
|
|
550
|
+
deadline = time.monotonic() + timeout
|
|
551
|
+
while time.monotonic() < deadline:
|
|
552
|
+
if not _pid_alive(pid):
|
|
553
|
+
typer.echo("✓ daemon stopped cleanly")
|
|
554
|
+
return
|
|
555
|
+
time.sleep(0.1)
|
|
556
|
+
typer.echo(f"daemon did not exit within {timeout}s; sending SIGKILL")
|
|
557
|
+
try:
|
|
558
|
+
os.kill(pid, signal.SIGKILL)
|
|
559
|
+
except ProcessLookupError:
|
|
560
|
+
pass
|
|
561
|
+
raise typer.Exit(1)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _pid_alive(pid: int) -> bool:
|
|
565
|
+
"""Return True if a process with this PID exists.
|
|
566
|
+
|
|
567
|
+
Uses os.kill(pid, 0) which sends signal 0 — does nothing but checks
|
|
568
|
+
permission/existence."""
|
|
569
|
+
try:
|
|
570
|
+
os.kill(pid, 0)
|
|
571
|
+
except ProcessLookupError:
|
|
572
|
+
return False
|
|
573
|
+
except PermissionError:
|
|
574
|
+
# Process exists, owned by another user
|
|
575
|
+
return True
|
|
576
|
+
return True
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
@app.command("install-service")
|
|
580
|
+
def install_service_cmd(
|
|
581
|
+
server_url: str | None = typer.Option(
|
|
582
|
+
None,
|
|
583
|
+
"--server-url",
|
|
584
|
+
help="Persisted in service unit (else uses ~/.cloudagent/daemon/config.json).",
|
|
585
|
+
),
|
|
586
|
+
api_key: str | None = typer.Option(
|
|
587
|
+
None,
|
|
588
|
+
"--api-key",
|
|
589
|
+
help="Persisted in service unit (else uses ~/.cloudagent/daemon/config.json).",
|
|
590
|
+
),
|
|
591
|
+
insecure: bool = typer.Option(
|
|
592
|
+
False,
|
|
593
|
+
"--insecure",
|
|
594
|
+
help="Pass --insecure on each spawn (skip TLS verification).",
|
|
595
|
+
),
|
|
596
|
+
workspace_root: Path | None = typer.Option(
|
|
597
|
+
None,
|
|
598
|
+
"--workspace-root",
|
|
599
|
+
"-w",
|
|
600
|
+
help=(
|
|
601
|
+
"Root directory for all agent workspaces, baked into the "
|
|
602
|
+
"service unit (default: ~/.cloudagent/agents/ or "
|
|
603
|
+
"~/.cloudagent/agents-<profile>/ for named profiles). "
|
|
604
|
+
"Tilde-expanded + resolved to an absolute path before "
|
|
605
|
+
"being written to the plist / systemd unit."
|
|
606
|
+
),
|
|
607
|
+
),
|
|
608
|
+
profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
|
|
609
|
+
) -> None:
|
|
610
|
+
"""Install daemon as a launchd (macOS) or systemd (Linux) user service.
|
|
611
|
+
|
|
612
|
+
Service auto-starts at login, auto-restarts on crash. Use uninstall-service
|
|
613
|
+
to remove. View logs:
|
|
614
|
+
|
|
615
|
+
macOS (default profile): tail -F ~/.cloudagent/daemon/logs/launchd.{out,err}.log
|
|
616
|
+
macOS (named profile fr): tail -F ~/.cloudagent/daemon/profiles/fr/logs/launchd.{out,err}.log
|
|
617
|
+
Linux: journalctl --user -u cloudagent-daemon[-<profile>] -f
|
|
618
|
+
"""
|
|
619
|
+
from .config import DaemonState
|
|
620
|
+
from .paths import (
|
|
621
|
+
DEFAULT_PROFILE,
|
|
622
|
+
profile_state_path,
|
|
623
|
+
profile_launchd_label,
|
|
624
|
+
profile_systemd_unit_name,
|
|
625
|
+
)
|
|
626
|
+
from .service import detect_platform, install_service
|
|
627
|
+
|
|
628
|
+
profile_name = _resolve_cli_profile(profile)
|
|
629
|
+
|
|
630
|
+
# Task #115 (2026-05-12) — same resolve sequence the ``start``
|
|
631
|
+
# command uses (tilde-expand → resolve → mkdir). We also persist
|
|
632
|
+
# to state.json so a later ``cloudagent-daemon start`` (no flag)
|
|
633
|
+
# picks up the same root.
|
|
634
|
+
resolved_workspace_root: str | None = None
|
|
635
|
+
if workspace_root is not None:
|
|
636
|
+
resolved = Path(
|
|
637
|
+
os.path.expanduser(str(workspace_root))
|
|
638
|
+
).resolve()
|
|
639
|
+
resolved.mkdir(parents=True, exist_ok=True)
|
|
640
|
+
resolved_workspace_root = str(resolved)
|
|
641
|
+
state = (
|
|
642
|
+
DaemonState.load_default(profile_name)
|
|
643
|
+
if profile_state_path(profile_name).exists()
|
|
644
|
+
else DaemonState()
|
|
645
|
+
)
|
|
646
|
+
state.workspace_root = resolved_workspace_root
|
|
647
|
+
state.save_default(profile_name)
|
|
648
|
+
|
|
649
|
+
# Surface "this is an update" rather than silently overwriting an
|
|
650
|
+
# existing plist/unit — proposal §2.1 step 5 commercial-grade ask.
|
|
651
|
+
existing_label = profile_launchd_label(profile_name)
|
|
652
|
+
existing_unit = profile_systemd_unit_name(profile_name)
|
|
653
|
+
plat_now = None
|
|
654
|
+
try:
|
|
655
|
+
plat_now = detect_platform()
|
|
656
|
+
except RuntimeError:
|
|
657
|
+
pass
|
|
658
|
+
if plat_now == "macos":
|
|
659
|
+
existing_path = (
|
|
660
|
+
Path.home() / "Library" / "LaunchAgents" / f"{existing_label}.plist"
|
|
661
|
+
)
|
|
662
|
+
if existing_path.exists():
|
|
663
|
+
typer.echo(
|
|
664
|
+
f"(updating existing profile {profile_name!r} — plist will be replaced)"
|
|
665
|
+
)
|
|
666
|
+
elif plat_now == "linux":
|
|
667
|
+
existing_path = Path.home() / ".config" / "systemd" / "user" / existing_unit
|
|
668
|
+
if existing_path.exists():
|
|
669
|
+
typer.echo(
|
|
670
|
+
f"(updating existing profile {profile_name!r} — unit file will be replaced)"
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
plat = detect_platform()
|
|
675
|
+
path = install_service(
|
|
676
|
+
server_url=server_url,
|
|
677
|
+
api_key=api_key,
|
|
678
|
+
insecure=insecure,
|
|
679
|
+
workspace_root=resolved_workspace_root,
|
|
680
|
+
profile=profile_name,
|
|
681
|
+
)
|
|
682
|
+
except RuntimeError as exc:
|
|
683
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
684
|
+
raise typer.Exit(1)
|
|
685
|
+
typer.echo(f"✓ Installed {plat} service unit: {path}")
|
|
686
|
+
if profile_name != DEFAULT_PROFILE:
|
|
687
|
+
typer.echo(f" profile: {profile_name}")
|
|
688
|
+
typer.echo("Daemon will auto-restart on crash and start at login.")
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@app.command("uninstall-service")
|
|
692
|
+
def uninstall_service_cmd(
|
|
693
|
+
profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
|
|
694
|
+
) -> None:
|
|
695
|
+
"""Stop + remove the launchd / systemd service unit for *profile*."""
|
|
696
|
+
from .service import detect_platform, uninstall_service
|
|
697
|
+
|
|
698
|
+
profile_name = _resolve_cli_profile(profile)
|
|
699
|
+
|
|
700
|
+
try:
|
|
701
|
+
plat = detect_platform()
|
|
702
|
+
removed = uninstall_service(profile=profile_name)
|
|
703
|
+
except RuntimeError as exc:
|
|
704
|
+
typer.echo(f"Error: {exc}", err=True)
|
|
705
|
+
raise typer.Exit(1)
|
|
706
|
+
if removed:
|
|
707
|
+
typer.echo(f"✓ Uninstalled {plat} service (profile={profile_name!r})")
|
|
708
|
+
else:
|
|
709
|
+
typer.echo(
|
|
710
|
+
f"({plat} service for profile {profile_name!r} was not installed)",
|
|
711
|
+
err=True,
|
|
712
|
+
)
|
|
713
|
+
raise typer.Exit(1)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
@app.command("list-profiles")
|
|
717
|
+
def list_profiles_cmd() -> None:
|
|
718
|
+
"""List installed daemon profiles with their server URLs and live status."""
|
|
719
|
+
from .config import DaemonConfig
|
|
720
|
+
from .paths import (
|
|
721
|
+
list_installed_profiles,
|
|
722
|
+
profile_pid_path,
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
profiles = list_installed_profiles()
|
|
726
|
+
if not profiles:
|
|
727
|
+
typer.echo("No profiles installed. Run `cloudagent-daemon login ...` first.")
|
|
728
|
+
return
|
|
729
|
+
|
|
730
|
+
for name in profiles:
|
|
731
|
+
try:
|
|
732
|
+
cfg = DaemonConfig.load_default(name)
|
|
733
|
+
server = cfg.server_url
|
|
734
|
+
host_id = cfg.host_id
|
|
735
|
+
except (FileNotFoundError, ValueError, OSError) as exc:
|
|
736
|
+
server = f"<unreadable: {exc.__class__.__name__}>"
|
|
737
|
+
host_id = "?"
|
|
738
|
+
pid_file = profile_pid_path(name)
|
|
739
|
+
running = "—"
|
|
740
|
+
pid_str = ""
|
|
741
|
+
if pid_file.exists():
|
|
742
|
+
try:
|
|
743
|
+
pid = int(pid_file.read_text().strip())
|
|
744
|
+
except (ValueError, OSError):
|
|
745
|
+
running = "stale-pid"
|
|
746
|
+
else:
|
|
747
|
+
running = "running" if _pid_alive(pid) else "stopped"
|
|
748
|
+
pid_str = f" pid={pid}"
|
|
749
|
+
typer.echo(f"● {name}")
|
|
750
|
+
typer.echo(f" server: {server}")
|
|
751
|
+
typer.echo(f" host_id: {host_id}")
|
|
752
|
+
typer.echo(f" status: {running}{pid_str}")
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
@app.command("mcp-bridge", hidden=True)
|
|
756
|
+
def mcp_bridge(
|
|
757
|
+
agent_id: str = typer.Option(..., "--agent-id", help="Agent ID this bridge serves."),
|
|
758
|
+
token_file: Path | None = typer.Option(
|
|
759
|
+
None,
|
|
760
|
+
"--token-file",
|
|
761
|
+
help=(
|
|
762
|
+
"Path to per-spawn token file (Phase 1: optional; tolerated "
|
|
763
|
+
"when missing — see 1i Bug B). Will be enforced once the "
|
|
764
|
+
"spec §6.6 per-spawn token scheme is fully wired."
|
|
765
|
+
),
|
|
766
|
+
),
|
|
767
|
+
profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
|
|
768
|
+
) -> None:
|
|
769
|
+
"""[internal] stdio MCP server invoked by claude subprocess."""
|
|
770
|
+
from .mcp_bridge import run_bridge
|
|
771
|
+
|
|
772
|
+
profile_name = _resolve_cli_profile(profile)
|
|
773
|
+
asyncio.run(
|
|
774
|
+
run_bridge(agent_id=agent_id, token_file=token_file, profile=profile_name)
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
@agent_app.command("register")
|
|
779
|
+
def agent_register(
|
|
780
|
+
agent_id: str = typer.Option(..., "--agent-id", help="Agent ID already bound on the server."),
|
|
781
|
+
role: str = typer.Option("staff", "--role", help="Agent role (staff or chief)."),
|
|
782
|
+
is_chief: bool = typer.Option(False, "--chief", help="Mark agent as chief."),
|
|
783
|
+
name: str | None = typer.Option(None, "--name", help="Human-readable agent name."),
|
|
784
|
+
workspace: str | None = typer.Option(None, "--workspace-slug", help="Workspace slug."),
|
|
785
|
+
reports_to: str | None = typer.Option(None, "--reports-to", help="Superior agent ID."),
|
|
786
|
+
profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
|
|
787
|
+
) -> None:
|
|
788
|
+
"""Create local workspace for an agent already bound on server."""
|
|
789
|
+
from .workspace import Workspace
|
|
790
|
+
|
|
791
|
+
from .paths import DEFAULT_PROFILE
|
|
792
|
+
|
|
793
|
+
profile_name = _resolve_cli_profile(profile)
|
|
794
|
+
identity = {
|
|
795
|
+
"agent_id": agent_id,
|
|
796
|
+
"role": role,
|
|
797
|
+
"is_chief": is_chief,
|
|
798
|
+
"agent_name": name or agent_id,
|
|
799
|
+
"workspace_slug": workspace or "default",
|
|
800
|
+
"reports_to": reports_to or ("self" if is_chief else "(unset)"),
|
|
801
|
+
}
|
|
802
|
+
# Backward-compat: default profile uses the old single-arg ctor so
|
|
803
|
+
# tests / downstream code that monkeypatches ``Workspace`` with a
|
|
804
|
+
# narrower stub keep working byte-for-byte.
|
|
805
|
+
ws_obj = (
|
|
806
|
+
Workspace()
|
|
807
|
+
if profile_name == DEFAULT_PROFILE
|
|
808
|
+
else Workspace(profile=profile_name)
|
|
809
|
+
)
|
|
810
|
+
ws = ws_obj.ensure(agent_id, identity)
|
|
811
|
+
suffix = "" if profile_name == DEFAULT_PROFILE else f" (profile={profile_name!r})"
|
|
812
|
+
typer.echo(f"✓ Registered {agent_id} at {ws.path}{suffix}")
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
@agent_app.command("unregister")
|
|
816
|
+
def agent_unregister(
|
|
817
|
+
agent_id: str = typer.Option(..., "--agent-id", help="Agent ID to remove locally."),
|
|
818
|
+
profile: str | None = typer.Option(None, "--profile", help=_PROFILE_HELP),
|
|
819
|
+
) -> None:
|
|
820
|
+
"""Mark local workspace as archived (30-day retention)."""
|
|
821
|
+
from .workspace import Workspace
|
|
822
|
+
|
|
823
|
+
from .paths import DEFAULT_PROFILE
|
|
824
|
+
|
|
825
|
+
profile_name = _resolve_cli_profile(profile)
|
|
826
|
+
ws_obj = (
|
|
827
|
+
Workspace()
|
|
828
|
+
if profile_name == DEFAULT_PROFILE
|
|
829
|
+
else Workspace(profile=profile_name)
|
|
830
|
+
)
|
|
831
|
+
ws_obj.archive(agent_id)
|
|
832
|
+
suffix = "" if profile_name == DEFAULT_PROFILE else f" (profile={profile_name!r})"
|
|
833
|
+
typer.echo(f"✓ Archived {agent_id}{suffix}")
|