cloudagent-daemon 0.4.1__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. cloudagent_daemon-0.5.0/CHANGELOG.md +71 -0
  2. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/PKG-INFO +55 -17
  3. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/README.md +50 -12
  4. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/pyproject.toml +9 -4
  5. cloudagent_daemon-0.5.0/scripts/release.sh +128 -0
  6. cloudagent_daemon-0.5.0/src/cloudagent_daemon/__init__.py +1 -0
  7. cloudagent_daemon-0.5.0/src/cloudagent_daemon/agent_download.py +131 -0
  8. cloudagent_daemon-0.5.0/src/cloudagent_daemon/auth_preflight.py +170 -0
  9. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/claude_runner.py +208 -6
  10. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/cli.py +149 -1
  11. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/doctor.py +134 -0
  12. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/frame_dispatch.py +34 -2
  13. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/http_outbound.py +14 -0
  14. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/main_loop.py +161 -13
  15. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/mcp_bridge.py +73 -5
  16. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/outbound_queue.py +3 -0
  17. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/protocol.py +9 -0
  18. cloudagent_daemon-0.5.0/src/cloudagent_daemon/qoder_credentials.py +175 -0
  19. cloudagent_daemon-0.5.0/src/cloudagent_daemon/runtime_config.py +78 -0
  20. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/runtime_detect.py +1 -0
  21. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/service.py +203 -2
  22. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/spawn_queue.py +15 -0
  23. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/integration/test_e2e_deliver_to_spawn.py +7 -0
  24. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/integration/test_e2e_sse.py +6 -0
  25. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/integration/test_full_e2e.py +6 -0
  26. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/integration/test_one_shot_cli.py +5 -0
  27. cloudagent_daemon-0.5.0/tests/unit/test_agent_download.py +141 -0
  28. cloudagent_daemon-0.5.0/tests/unit/test_auth_preflight.py +188 -0
  29. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_claude_runner.py +390 -2
  30. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_cli_smoke.py +29 -0
  31. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_doctor.py +150 -0
  32. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_frame_dispatch.py +40 -0
  33. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_main_loop_assign.py +24 -0
  34. cloudagent_daemon-0.5.0/tests/unit/test_main_loop_preflight.py +68 -0
  35. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_mcp_bridge_agent_retrieval.py +76 -0
  36. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_outbound_queue.py +11 -0
  37. cloudagent_daemon-0.5.0/tests/unit/test_packaging_mcp_pin.py +33 -0
  38. cloudagent_daemon-0.5.0/tests/unit/test_qoder_credentials.py +65 -0
  39. cloudagent_daemon-0.5.0/tests/unit/test_service.py +782 -0
  40. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_spawn_queue.py +22 -0
  41. cloudagent_daemon-0.5.0/tests/unit/test_start_guard.py +42 -0
  42. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/uv.lock +2 -2
  43. cloudagent_daemon-0.4.1/src/cloudagent_daemon/__init__.py +0 -1
  44. cloudagent_daemon-0.4.1/tests/unit/test_service.py +0 -308
  45. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/.gitignore +0 -0
  46. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/LICENSE +0 -0
  47. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/__main__.py +0 -0
  48. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/agent_upload.py +0 -0
  49. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/agent_upload_denylist.py +0 -0
  50. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/config.py +0 -0
  51. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/context_fetch.py +0 -0
  52. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/delegation_report.py +0 -0
  53. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/injection_state.py +0 -0
  54. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/jsonl_logging.py +0 -0
  55. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/login.py +0 -0
  56. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/metrics.py +0 -0
  57. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/metrics_server.py +0 -0
  58. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/paths.py +0 -0
  59. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/readonly_bash_guard.py +0 -0
  60. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/spawn_outbox.py +0 -0
  61. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/sse_client.py +0 -0
  62. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/transport.py +0 -0
  63. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/unix_socket.py +0 -0
  64. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/workspace.py +0 -0
  65. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/src/cloudagent_daemon/ws_client.py +0 -0
  66. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/integration/__init__.py +0 -0
  67. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/integration/_fake_sse_server.py +0 -0
  68. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/integration/conftest.py +0 -0
  69. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/integration/test_mcp_bridge_round_trip.py +0 -0
  70. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/integration/test_spawn_mcp_bridge_e2e.py +0 -0
  71. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/integration/test_sse_handshake.py +0 -0
  72. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/integration/test_sse_watchdog.py +0 -0
  73. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/integration/test_ws_handshake.py +0 -0
  74. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/integration/test_ws_reconnect.py +0 -0
  75. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/conftest.py +0 -0
  76. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_agent_upload.py +0 -0
  77. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_claude_runner_codex_contract.py +0 -0
  78. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_claude_runner_routing_contract.py +0 -0
  79. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_claude_runner_w3.py +0 -0
  80. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_cli_multi_profile.py +0 -0
  81. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_config.py +0 -0
  82. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_context_fetch.py +0 -0
  83. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_delegation_report.py +0 -0
  84. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_http_outbound.py +0 -0
  85. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_injection_state.py +0 -0
  86. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_jsonl_logging.py +0 -0
  87. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_login.py +0 -0
  88. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_mcp_bridge_send_message.py +0 -0
  89. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_metrics.py +0 -0
  90. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_metrics_server.py +0 -0
  91. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_multi_tool_wake.py +0 -0
  92. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_outbox_stuck_watchdog.py +0 -0
  93. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_paths.py +0 -0
  94. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_protocol.py +0 -0
  95. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_readonly_bash_guard.py +0 -0
  96. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_runtime_detect.py +0 -0
  97. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_service_multi_profile.py +0 -0
  98. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_spawn_outbox.py +0 -0
  99. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_sse_active_watchdog.py +0 -0
  100. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_status_stop.py +0 -0
  101. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_workspace.py +0 -0
  102. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_workspace_root_task113.py +0 -0
  103. {cloudagent_daemon-0.4.1 → cloudagent_daemon-0.5.0}/tests/unit/test_ws_client_insecure.py +0 -0
@@ -0,0 +1,71 @@
1
+ # Changelog — cloudagent-daemon
2
+
3
+ All notable changes to the local daemon. Format follows
4
+ [Keep a Changelog](https://keepachangelog.com/); versions are
5
+ [PEP 440](https://peps.python.org/pep-0440/). Release with `scripts/release.sh`.
6
+
7
+ ## [0.5.0] — 2026-08-19
8
+
9
+ First release since 0.4.1 (2026-07-22). Everything below shipped to internal
10
+ hosts from source; this is the first time it reaches the PyPI package.
11
+
12
+ ### Added
13
+ - **No-source installation and release tooling** (#96): a repeatable,
14
+ fail-closed PyPI release script, end-user installation guide, and
15
+ `cloudagent-daemon --version` / `-V` for verifying installed builds.
16
+ - **Qoder CLI runtime engine** (`runtime_engine=qoder`, #86/#87): `qodercli`
17
+ auto-detected, headless spawn via `-p --output-format stream-json`, model
18
+ and reasoning-effort passthrough, login probe in the periodic health loop.
19
+ `cloudagent-daemon setup-qoder-token` stores the Personal Access Token in
20
+ the macOS login Keychain (Linux: 0600 file) and injects it only into the
21
+ Qoder child process — never argv / plist / config / logs.
22
+ - **Login-session install** (#25): `cloudagent-daemon install-service` now
23
+ installs the daemon as a `gui/$UID` LaunchAgent (macOS) / user systemd unit
24
+ (Linux) so spawned CLIs can read the login keychain; boot-time auth
25
+ pre-flight logs an actionable remedy instead of a silent crash loop.
26
+ - **Periodic auth health → heartbeat** (#53): every 5 min the daemon probes
27
+ CLI login state and uplinks `runtime_health` with the heartbeat; the
28
+ backend refuses to dispatch to an agent whose runtime is logged out
29
+ (`STAFF_UNAVAILABLE`) instead of crash-quarantining it.
30
+ - **Attachments reach agents** (#48): `download_attachment` MCP tool + wake
31
+ body `【附件】` block; agents can fetch and read images/files shared in chat.
32
+ - **Real reminders** (#49): `schedule_reminder` / `list_reminders` /
33
+ `cancel_reminder` MCP tools (one-shot and cron, 5-minute floor) that truly
34
+ re-wake the agent.
35
+ - Per-agent runtime config at creation (model, reasoning effort, runtime
36
+ env) threaded through assign/identity to the spawn (#22).
37
+ - `acknowledge_new_messages` forwarded on `platform_send` (#28).
38
+
39
+ ### Fixed
40
+ - **Qoder per-conversation session lanes** (#95): Qoder runtime sessions are
41
+ keyed by `conversation_id` so DM/#channel wakes never resume each other's
42
+ session; the per-channel system prompt (#88) is carried in the stdin
43
+ preamble every turn (qodercli `--resume` did not reliably adopt a new
44
+ `--append-system-prompt`).
45
+ - An `ack` POST failure could swallow a deliver's spawn (message lost until
46
+ the 120-min watchdog); ack and spawn are now independently contained.
47
+ - `mcp` pinned `>=1.9,<2` — mcp 2.x removed FastMCP and broke a fresh
48
+ `uv tool install` (#28).
49
+ - Agent working state no longer flickers idle while conversations are queued.
50
+
51
+ ### Changed
52
+ - Runtime engines advertised to the backend: `claude_code`, `codex`, `qoder`.
53
+ - Qoder `read_analyze` (read-only) wakes are refused (fail-closed) until the
54
+ tool-restriction flags are verified on a real login.
55
+
56
+ ## [0.4.1] — 2026-07-22
57
+ ### Fixed
58
+ - Runtime detection: a probe that exits non-zero is reported as version
59
+ `unknown` instead of parsing its error text as a version (a broken nvm
60
+ shim no longer outranks a working install).
61
+
62
+ ## [0.4.0] — 2026-07
63
+ - Multi-runtime detection across PATH + well-known dirs, newest version wins.
64
+ - Daemon profiles, SSE+POST transport default, spawn outbox / watchdog.
65
+
66
+ ## [0.3.x] — 2026-05/06
67
+ - M7 W3: local-agent context injection, delegation token/status uplink.
68
+ - Token-level streaming of replies, per-agent `workspace_root`.
69
+
70
+ ## [0.2.x] — 2026-05
71
+ - Initial public releases: spawn-on-message daemon, embedded MCP bridge.
@@ -1,14 +1,14 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: cloudagent-daemon
3
- Version: 0.4.1
4
- Summary: Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket.
3
+ Version: 0.5.0
4
+ Summary: Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend.
5
5
  Project-URL: Homepage, https://cloudagent.chat
6
6
  Project-URL: Repository, https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent
7
7
  Project-URL: Documentation, https://codeup.aliyun.com/69bd0a4d29ad98af4065ce71/cloudagent/docs/superpowers/specs
8
8
  Author-email: chaoyin <tudoucyx@gmail.com>
9
9
  License-Expression: MIT
10
10
  License-File: LICENSE
11
- Keywords: ai-coding-agent,claude-code,cloudagent,daemon
11
+ Keywords: ai-coding-agent,claude-code,cloudagent,codex,daemon,qoder
12
12
  Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Operating System :: MacOS
14
14
  Classifier: Operating System :: POSIX :: Linux
@@ -20,7 +20,7 @@ Requires-Python: >=3.12
20
20
  Requires-Dist: h2>=4
21
21
  Requires-Dist: httpx-sse>=0.4
22
22
  Requires-Dist: httpx>=0.27
23
- Requires-Dist: mcp>=0.9
23
+ Requires-Dist: mcp<2,>=1.9
24
24
  Requires-Dist: pydantic>=2.7
25
25
  Requires-Dist: typer>=0.12
26
26
  Requires-Dist: websockets>=12
@@ -32,19 +32,23 @@ Description-Content-Type: text/markdown
32
32
 
33
33
  # cloudagent-daemon
34
34
 
35
- Local daemon for cloudagent — runs on user's machine, bridges Claude Code
36
- subprocesses to the cloudagent backend so that agents can work locally with
37
- your machine's filesystem and tools.
35
+ Local daemon for cloudagent — runs on the user's machine and bridges AI coding
36
+ CLIs (**Claude Code**, **Codex**, **Qoder CLI**) to the cloudagent backend so
37
+ agents can work locally with your machine's filesystem and tools.
38
38
 
39
- **Architecture:** spawn-on-message model — daemon receives a `deliver` frame over
40
- WebSocket, spawns `claude --resume <session_id>`, claude uses an MCP server
41
- (also embedded in this daemon) to `send_message` / `list_messages` back to the
42
- backend, and exits when its work is done.
39
+ **Architecture:** spawn-on-message — the daemon keeps an SSE stream to the
40
+ backend, receives a `deliver` frame per wake, spawns the agent's CLI
41
+ (`claude` / `codex` / `qodercli`, resuming that agent's session), and the CLI
42
+ talks back through an MCP bridge embedded in this daemon (`platform_send`,
43
+ `send_message`, `download_attachment`, reminders…) before exiting.
43
44
 
44
- ## Quick start (no clone)
45
+ ## Quick start (no clone) — for end users
45
46
 
46
47
  Published to PyPI as [`cloudagent-daemon`](https://pypi.org/project/cloudagent-daemon/).
47
- Requires Python 3.12+ and Claude Code CLI on PATH.
48
+ The repository also contains a step-by-step Chinese walkthrough at
49
+ `docs/daemon-install-guide.md`. Requirements: Python 3.12+ (`uv` will fetch one if missing),
50
+ [`uv`](https://docs.astral.sh/uv/), and at least one agent CLI on PATH and
51
+ logged in — `claude` (Claude Code), `codex`, or `qodercli`.
48
52
 
49
53
  ```bash
50
54
  # One-shot run (no persistent install)
@@ -52,8 +56,10 @@ uvx cloudagent-daemon start --server-url https://api.cloudagent.chat --api-key s
52
56
  ```
53
57
 
54
58
  ```bash
55
- # Persistent install — install + start chained with && (single line)
56
- uv tool install cloudagent-daemon && cloudagent-daemon start --server-url https://api.cloudagent.chat --api-key sk_machine_***
59
+ # Persistent install — save the machine identity, then install the service
60
+ uv tool install cloudagent-daemon
61
+ cloudagent-daemon login --server https://api.cloudagent.chat # paste the key at the hidden prompt
62
+ cloudagent-daemon install-service
57
63
  ```
58
64
 
59
65
  ```bash
@@ -61,10 +67,23 @@ uv tool install cloudagent-daemon && cloudagent-daemon start --server-url https:
61
67
  pipx install cloudagent-daemon
62
68
  ```
63
69
 
70
+ `login` stores the machine identity in a mode-0600 local config file;
71
+ `install-service` then starts a macOS `gui/$UID` LaunchAgent or Linux user
72
+ systemd unit from that config. Do not substitute a one-shot `start
73
+ --server-url ... --api-key ...` here: one-shot mode intentionally persists no
74
+ credentials, so a later argument-free service would start unauthenticated.
75
+
76
+ Qoder CLI agents need a Personal Access Token stored once per machine
77
+ (Keychain on macOS, 0600 file on Linux; never written to plist/config/logs):
78
+
79
+ ```bash
80
+ cloudagent-daemon setup-qoder-token
81
+ ```
82
+
64
83
  To upgrade later:
65
84
 
66
85
  ```bash
67
- uv tool upgrade cloudagent-daemon
86
+ uv tool upgrade cloudagent-daemon && cloudagent-daemon install-service # re-install picks up the new binary
68
87
  # or
69
88
  pipx upgrade cloudagent-daemon
70
89
  ```
@@ -133,6 +152,8 @@ cloudagent-daemon start \
133
152
  # Output:
134
153
  # Detected runtimes:
135
154
  # ✓ claude_code 2.1.137 (/opt/homebrew/bin/claude)
155
+ # ✓ codex 0.145.0 (/opt/homebrew/bin/codex)
156
+ # ✓ qoder 1.1.25 (/Users/me/.local/bin/qodercli)
136
157
  # Connecting to https://api.cloudagent.chat as host host_01HXX...
137
158
  ```
138
159
 
@@ -168,6 +189,7 @@ no state behind. For persistent setups, use `login` once + `start` after.
168
189
  | `cloudagent-daemon stop` | Send SIGTERM, wait for clean exit |
169
190
  | `cloudagent-daemon install-service` | Install + start as launchd / systemd user service (auto-restart on crash, auto-start at login) |
170
191
  | `cloudagent-daemon uninstall-service` | Stop + remove the launchd / systemd service unit |
192
+ | `cloudagent-daemon setup-qoder-token` | Store the Qoder CLI Personal Access Token securely (Keychain / 0600 file) for Qoder agents |
171
193
  | `cloudagent-daemon agent register --agent-id ID` | Create local workspace |
172
194
  | `cloudagent-daemon agent unregister --agent-id ID` | Archive workspace |
173
195
 
@@ -309,3 +331,19 @@ See `docs/superpowers/specs/2026-05-08-local-daemon-runtime-design.md`.
309
331
  ## License
310
332
 
311
333
  MIT — see [LICENSE](./LICENSE).
334
+
335
+ ## Releasing (maintainers)
336
+
337
+ ```bash
338
+ # 1. bump version in pyproject.toml AND src/cloudagent_daemon/__init__.py (must match)
339
+ # 2. add a "## [x.y.z]" section to CHANGELOG.md
340
+ # 3. commit, then:
341
+ UV_PUBLISH_TOKEN=pypi-... scripts/release.sh # or scripts/release.sh --dry-run
342
+ git push cloudagent daemon-v<x.y.z>
343
+ ```
344
+
345
+ The script refuses to publish on a dirty tree, a version/lockfile mismatch, a
346
+ missing changelog entry, an ambiguous PyPI response, an existing version, or a
347
+ conflicting tag. It runs the unit suite with a scrubbed environment, builds,
348
+ and validates both distribution artifacts first. The token is read from the
349
+ environment only — never commit it.
@@ -1,18 +1,22 @@
1
1
  # cloudagent-daemon
2
2
 
3
- Local daemon for cloudagent — runs on user's machine, bridges Claude Code
4
- subprocesses to the cloudagent backend so that agents can work locally with
5
- your machine's filesystem and tools.
3
+ Local daemon for cloudagent — runs on the user's machine and bridges AI coding
4
+ CLIs (**Claude Code**, **Codex**, **Qoder CLI**) to the cloudagent backend so
5
+ agents can work locally with your machine's filesystem and tools.
6
6
 
7
- **Architecture:** spawn-on-message model — daemon receives a `deliver` frame over
8
- WebSocket, spawns `claude --resume <session_id>`, claude uses an MCP server
9
- (also embedded in this daemon) to `send_message` / `list_messages` back to the
10
- backend, and exits when its work is done.
7
+ **Architecture:** spawn-on-message — the daemon keeps an SSE stream to the
8
+ backend, receives a `deliver` frame per wake, spawns the agent's CLI
9
+ (`claude` / `codex` / `qodercli`, resuming that agent's session), and the CLI
10
+ talks back through an MCP bridge embedded in this daemon (`platform_send`,
11
+ `send_message`, `download_attachment`, reminders…) before exiting.
11
12
 
12
- ## Quick start (no clone)
13
+ ## Quick start (no clone) — for end users
13
14
 
14
15
  Published to PyPI as [`cloudagent-daemon`](https://pypi.org/project/cloudagent-daemon/).
15
- Requires Python 3.12+ and Claude Code CLI on PATH.
16
+ The repository also contains a step-by-step Chinese walkthrough at
17
+ `docs/daemon-install-guide.md`. Requirements: Python 3.12+ (`uv` will fetch one if missing),
18
+ [`uv`](https://docs.astral.sh/uv/), and at least one agent CLI on PATH and
19
+ logged in — `claude` (Claude Code), `codex`, or `qodercli`.
16
20
 
17
21
  ```bash
18
22
  # One-shot run (no persistent install)
@@ -20,8 +24,10 @@ uvx cloudagent-daemon start --server-url https://api.cloudagent.chat --api-key s
20
24
  ```
21
25
 
22
26
  ```bash
23
- # Persistent install — install + start chained with && (single line)
24
- uv tool install cloudagent-daemon && cloudagent-daemon start --server-url https://api.cloudagent.chat --api-key sk_machine_***
27
+ # Persistent install — save the machine identity, then install the service
28
+ uv tool install cloudagent-daemon
29
+ cloudagent-daemon login --server https://api.cloudagent.chat # paste the key at the hidden prompt
30
+ cloudagent-daemon install-service
25
31
  ```
26
32
 
27
33
  ```bash
@@ -29,10 +35,23 @@ uv tool install cloudagent-daemon && cloudagent-daemon start --server-url https:
29
35
  pipx install cloudagent-daemon
30
36
  ```
31
37
 
38
+ `login` stores the machine identity in a mode-0600 local config file;
39
+ `install-service` then starts a macOS `gui/$UID` LaunchAgent or Linux user
40
+ systemd unit from that config. Do not substitute a one-shot `start
41
+ --server-url ... --api-key ...` here: one-shot mode intentionally persists no
42
+ credentials, so a later argument-free service would start unauthenticated.
43
+
44
+ Qoder CLI agents need a Personal Access Token stored once per machine
45
+ (Keychain on macOS, 0600 file on Linux; never written to plist/config/logs):
46
+
47
+ ```bash
48
+ cloudagent-daemon setup-qoder-token
49
+ ```
50
+
32
51
  To upgrade later:
33
52
 
34
53
  ```bash
35
- uv tool upgrade cloudagent-daemon
54
+ uv tool upgrade cloudagent-daemon && cloudagent-daemon install-service # re-install picks up the new binary
36
55
  # or
37
56
  pipx upgrade cloudagent-daemon
38
57
  ```
@@ -101,6 +120,8 @@ cloudagent-daemon start \
101
120
  # Output:
102
121
  # Detected runtimes:
103
122
  # ✓ claude_code 2.1.137 (/opt/homebrew/bin/claude)
123
+ # ✓ codex 0.145.0 (/opt/homebrew/bin/codex)
124
+ # ✓ qoder 1.1.25 (/Users/me/.local/bin/qodercli)
104
125
  # Connecting to https://api.cloudagent.chat as host host_01HXX...
105
126
  ```
106
127
 
@@ -136,6 +157,7 @@ no state behind. For persistent setups, use `login` once + `start` after.
136
157
  | `cloudagent-daemon stop` | Send SIGTERM, wait for clean exit |
137
158
  | `cloudagent-daemon install-service` | Install + start as launchd / systemd user service (auto-restart on crash, auto-start at login) |
138
159
  | `cloudagent-daemon uninstall-service` | Stop + remove the launchd / systemd service unit |
160
+ | `cloudagent-daemon setup-qoder-token` | Store the Qoder CLI Personal Access Token securely (Keychain / 0600 file) for Qoder agents |
139
161
  | `cloudagent-daemon agent register --agent-id ID` | Create local workspace |
140
162
  | `cloudagent-daemon agent unregister --agent-id ID` | Archive workspace |
141
163
 
@@ -277,3 +299,19 @@ See `docs/superpowers/specs/2026-05-08-local-daemon-runtime-design.md`.
277
299
  ## License
278
300
 
279
301
  MIT — see [LICENSE](./LICENSE).
302
+
303
+ ## Releasing (maintainers)
304
+
305
+ ```bash
306
+ # 1. bump version in pyproject.toml AND src/cloudagent_daemon/__init__.py (must match)
307
+ # 2. add a "## [x.y.z]" section to CHANGELOG.md
308
+ # 3. commit, then:
309
+ UV_PUBLISH_TOKEN=pypi-... scripts/release.sh # or scripts/release.sh --dry-run
310
+ git push cloudagent daemon-v<x.y.z>
311
+ ```
312
+
313
+ The script refuses to publish on a dirty tree, a version/lockfile mismatch, a
314
+ missing changelog entry, an ambiguous PyPI response, an existing version, or a
315
+ conflicting tag. It runs the unit suite with a scrubbed environment, builds,
316
+ and validates both distribution artifacts first. The token is read from the
317
+ environment only — never commit it.
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cloudagent-daemon"
7
- version = "0.4.1"
8
- description = "Local daemon for cloudagent — bridges Claude Code subprocesses to the cloudagent backend over WebSocket."
7
+ version = "0.5.0"
8
+ description = "Local daemon for cloudagent — bridges Claude Code, Codex, and Qoder CLI agents to the cloudagent backend."
9
9
  authors = [{name = "chaoyin", email = "tudoucyx@gmail.com"}]
10
10
  readme = "README.md"
11
11
  license = "MIT"
12
- keywords = ["cloudagent", "claude-code", "daemon", "ai-coding-agent"]
12
+ keywords = ["cloudagent", "claude-code", "codex", "qoder", "daemon", "ai-coding-agent"]
13
13
  classifiers = [
14
14
  "Development Status :: 4 - Beta",
15
15
  "Programming Language :: Python :: 3.12",
@@ -33,7 +33,12 @@ dependencies = [
33
33
  # ``:hb`` / deliver events flow as intended. Public endpoints are
34
34
  # unaffected (and h2 just downgrades to 1.1 if upstream rejects it).
35
35
  "h2>=4",
36
- "mcp>=0.9",
36
+ # #28 — pin BELOW 2.0: the bridge imports ``mcp.server.fastmcp`` (FastMCP),
37
+ # which mcp 2.x removed/relocated. An unpinned ``mcp>=0.9`` let a fresh
38
+ # uv-tool install resolve mcp==2.0.0 → ``ModuleNotFoundError`` at
39
+ # mcp-bridge startup (Cris's tools dead until a manual downgrade). Keep it
40
+ # on the 1.x line that ships FastMCP.
41
+ "mcp>=1.9,<2",
37
42
  ]
38
43
 
39
44
  [project.urls]
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env bash
2
+ # cloudagent-daemon release — repeatable PyPI publish (#96).
3
+ #
4
+ # Usage:
5
+ # UV_PUBLISH_TOKEN=pypi-xxx scripts/release.sh # build + test + publish
6
+ # scripts/release.sh --dry-run # everything except upload
7
+ # scripts/release.sh --skip-tests # (not recommended)
8
+ #
9
+ # What it enforces, in order:
10
+ # 1. clean git tree inside daemon_cli/ (no half-committed release)
11
+ # 2. pyproject.toml version == cloudagent_daemon.__version__
12
+ # 3. uv.lock is current and CHANGELOG.md has a section for that version
13
+ # 4. PyPI is reachable and the version is not already published
14
+ # 5. the release tag, if already present, points at the current commit
15
+ # 6. unit tests pass (set CLOUDAGENT_DAEMON_PROFILE unset — host env pollution
16
+ # is the known cause of the sandbox-only failures; we run with a scrubbed env)
17
+ # 7. uv build (sdist + wheel) into a fresh dist/ and validate both artifacts
18
+ # 8. uv publish (token from UV_PUBLISH_TOKEN; never stored in the repo)
19
+ # 9. git tag daemon-v<version> (local; push is left to the operator)
20
+ set -euo pipefail
21
+
22
+ DRY_RUN=0
23
+ SKIP_TESTS=0
24
+ for arg in "$@"; do
25
+ case "$arg" in
26
+ --dry-run) DRY_RUN=1 ;;
27
+ --skip-tests) SKIP_TESTS=1 ;;
28
+ -h|--help) sed -n '2,20p' "$0"; exit 0 ;;
29
+ *) echo "unknown arg: $arg" >&2; exit 2 ;;
30
+ esac
31
+ done
32
+
33
+ HERE="$(cd "$(dirname "$0")/.." && pwd)"
34
+ cd "$HERE"
35
+
36
+ command -v uv >/dev/null || { echo "uv not found — install: curl -LsSf https://astral.sh/uv/install.sh | sh" >&2; exit 1; }
37
+
38
+ # 1. clean tree (daemon_cli only — the monorepo may have unrelated WIP)
39
+ if [ -n "$(git status --porcelain -- . )" ]; then
40
+ echo "refusing to release with uncommitted changes under daemon_cli/:" >&2
41
+ git status --short -- . >&2
42
+ exit 1
43
+ fi
44
+
45
+ # 2. version consistency
46
+ PYPROJECT_VER="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -1)"
47
+ INIT_VER="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' src/cloudagent_daemon/__init__.py | head -1)"
48
+ if [ -z "$PYPROJECT_VER" ] || [ "$PYPROJECT_VER" != "$INIT_VER" ]; then
49
+ echo "version mismatch: pyproject=$PYPROJECT_VER __init__=$INIT_VER" >&2
50
+ exit 1
51
+ fi
52
+ VER="$PYPROJECT_VER"
53
+ TAG="daemon-v$VER"
54
+ echo "== releasing cloudagent-daemon $VER"
55
+
56
+ # 3. lockfile current + changelog section present. A version bump changes the
57
+ # editable root package entry in uv.lock; without this check `uv run` below
58
+ # silently rewrites the tracked lockfile after the one-time clean-tree check.
59
+ if ! uv lock --check; then
60
+ echo "uv.lock is stale — run 'uv lock' and commit the result before releasing" >&2
61
+ exit 1
62
+ fi
63
+ if ! grep -q "^## \[$VER\]" CHANGELOG.md; then
64
+ echo "CHANGELOG.md has no '## [$VER]' section — write the release notes first" >&2
65
+ exit 1
66
+ fi
67
+
68
+ # 4. PyPI reachable + version absent. Only an explicit 404 means it is safe
69
+ # to continue; DNS/TLS/timeouts/5xx must fail closed rather than being mistaken
70
+ # for "version does not exist".
71
+ if ! PYPI_STATUS="$(curl -sS -m 15 -o /dev/null -w '%{http_code}' \
72
+ "https://pypi.org/pypi/cloudagent-daemon/$VER/json")"; then
73
+ echo "could not verify cloudagent-daemon $VER on PyPI — refusing to publish" >&2
74
+ exit 1
75
+ fi
76
+ case "$PYPI_STATUS" in
77
+ 200)
78
+ echo "cloudagent-daemon $VER already exists on PyPI — bump the version" >&2
79
+ exit 1
80
+ ;;
81
+ 404) ;;
82
+ *)
83
+ echo "PyPI version check returned HTTP $PYPI_STATUS — refusing to publish" >&2
84
+ exit 1
85
+ ;;
86
+ esac
87
+
88
+ # 5. Never publish a commit whose release tag names a different commit. An
89
+ # existing same-commit tag is allowed so a failed upload can be retried safely.
90
+ if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
91
+ if [ "$(git rev-list -n 1 "$TAG")" != "$(git rev-parse HEAD)" ]; then
92
+ echo "$TAG already exists but does not point at HEAD — refusing to publish" >&2
93
+ exit 1
94
+ fi
95
+ echo "== existing release tag $TAG already points at HEAD"
96
+ fi
97
+
98
+ # 6. tests (scrubbed env: profile/server vars from the operator's shell must not leak)
99
+ if [ "$SKIP_TESTS" = "0" ]; then
100
+ echo "== unit tests"
101
+ env -u CLOUDAGENT_DAEMON_PROFILE -u CLOUDAGENT_SERVER_URL -u CLOUDAGENT_API_KEY \
102
+ uv run --extra dev python -m pytest tests/unit -q -x -p no:cacheprovider
103
+ fi
104
+
105
+ # 7. build + metadata validation
106
+ echo "== build"
107
+ rm -rf dist
108
+ uv build
109
+ ls -l dist/
110
+ uvx --from twine twine check dist/*
111
+
112
+ # 8. publish
113
+ if [ "$DRY_RUN" = "1" ]; then
114
+ echo "== dry-run: skipping upload. Artifacts in dist/ — inspect with:"
115
+ echo " uv run --with twine twine check dist/*"
116
+ exit 0
117
+ fi
118
+ : "${UV_PUBLISH_TOKEN:?set UV_PUBLISH_TOKEN=pypi-... (project-scoped token recommended; never commit it)}"
119
+ echo "== publish"
120
+ uv publish dist/*
121
+
122
+ # 9. tag. Do not swallow tag failures after a successful upload.
123
+ if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
124
+ git tag -a "$TAG" -m "cloudagent-daemon $VER"
125
+ fi
126
+ echo "== done: https://pypi.org/project/cloudagent-daemon/$VER/"
127
+ echo " push the tag: git push cloudagent $TAG"
128
+ echo " verify fresh install: uvx --refresh cloudagent-daemon@$VER --version"
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -0,0 +1,131 @@
1
+ """#48 — ``download_attachment``: fetch a platform attachment for a local agent.
2
+
3
+ The read twin of ``agent_upload``. The agent (claude) calls the bridge tool
4
+ with an ``attachment_id`` (from the wake body's 【附件】 block or a
5
+ ``platform_read`` message's ``attachments``); daemon-main pulls the bytes from
6
+ ``/v1/daemon/agent-file-download`` (machine-key auth; visibility enforced
7
+ server-side by the shared #51 gate) and lands them under the agent's OWN cwd
8
+ (``<cwd>/attachments/``) so the agent's Read tool can open the file — images
9
+ render multimodally, so "拿到图片" means literally seeing the picture.
10
+
11
+ Runs in daemon-main (like ``upload_file``): the backend dispatcher can't write
12
+ Mac paths, and the bytes must NOT ride the JSON tool-result channel.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+ from typing import Any
19
+ from urllib.parse import unquote
20
+
21
+ import httpx
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _PATH_AGENT_DOWNLOAD = "/v1/daemon/agent-file-download"
26
+
27
+ # Windows-reserved + path-separator + control chars — enough to keep a hostile
28
+ # server-supplied filename inside the attachments dir on macOS/Linux.
29
+ _BAD_FILENAME_CHARS = re.compile(r"[\\/\0\r\n:*?\"<>|]")
30
+
31
+
32
+ def _err(code: str, message: str) -> dict[str, Any]:
33
+ return {"ok": False, "error": {"code": code, "message": message}}
34
+
35
+
36
+ def _filename_from_headers(headers: dict, fallback: str) -> str:
37
+ """RFC 5987 ``filename*=UTF-8''…`` first, then plain ``filename=…``."""
38
+ cd = ""
39
+ for k, v in headers.items():
40
+ if str(k).lower() == "content-disposition":
41
+ cd = str(v)
42
+ break
43
+ m = re.search(r"filename\*=UTF-8''([^;]+)", cd)
44
+ if m:
45
+ name = unquote(m.group(1))
46
+ else:
47
+ m = re.search(r'filename="?([^";]+)"?', cd)
48
+ name = m.group(1) if m else fallback
49
+ # basename + character sanitation: never let a server-supplied name escape
50
+ # the attachments dir or smuggle separators.
51
+ name = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
52
+ name = _BAD_FILENAME_CHARS.sub("_", name).strip().strip(".")
53
+ return name or fallback
54
+
55
+
56
+ async def handle_download_attachment(
57
+ http: Any,
58
+ workspace: Any,
59
+ *,
60
+ agent_id: str,
61
+ session_id: str | None,
62
+ args: dict[str, Any],
63
+ ) -> dict[str, Any]:
64
+ """Download ``args['attachment_id']`` into the agent's cwd; return the path."""
65
+ att_id = str(args.get("attachment_id") or "").strip()
66
+ if not att_id:
67
+ return _err("INVALID_ARGS", "attachment_id is required")
68
+
69
+ try:
70
+ data, headers = await http.get_bytes(
71
+ _PATH_AGENT_DOWNLOAD,
72
+ params={"agent_id": agent_id, "attachment_id": att_id},
73
+ )
74
+ except httpx.HTTPStatusError as exc:
75
+ status = exc.response.status_code
76
+ if status == 404:
77
+ return _err(
78
+ "NOT_FOUND",
79
+ "attachment not found or not visible to this agent "
80
+ "(需要你是该会话的成员,且消息未被删除)",
81
+ )
82
+ logger.warning(
83
+ "agent_download: backend %d for att=%s agent=%s",
84
+ status, att_id, agent_id,
85
+ )
86
+ return _err("DOWNLOAD_FAILED", f"backend returned HTTP {status}")
87
+ except Exception as exc: # noqa: BLE001
88
+ logger.warning(
89
+ "agent_download: fetch failed att=%s: %s", att_id, exc, exc_info=True
90
+ )
91
+ return _err("DOWNLOAD_FAILED", f"download failed: {type(exc).__name__}")
92
+
93
+ if not data:
94
+ return _err("EMPTY_FILE", "attachment has no bytes")
95
+
96
+ filename = _filename_from_headers(headers, fallback=att_id)
97
+ mime = ""
98
+ for k, v in headers.items():
99
+ if str(k).lower() == "content-type":
100
+ mime = str(v)
101
+ break
102
+
103
+ try:
104
+ dest_dir = workspace.for_agent(agent_id).cwd / "attachments"
105
+ dest_dir.mkdir(parents=True, exist_ok=True)
106
+ dest = dest_dir / filename
107
+ if dest.exists():
108
+ # Same name ≠ same file — prefix with the (unique) attachment id.
109
+ dest = dest_dir / f"{att_id[-8:]}-{filename}"
110
+ dest.write_bytes(data)
111
+ except OSError as exc:
112
+ logger.warning("agent_download: write failed att=%s: %s", att_id, exc)
113
+ return _err("WRITE_FAILED", f"could not write file: {exc}")
114
+
115
+ logger.info(
116
+ "agent_download: saved att=%s agent=%s → %s (%d bytes)",
117
+ att_id, agent_id, dest, len(data),
118
+ )
119
+ return {
120
+ "ok": True,
121
+ "data": {
122
+ "local_path": str(dest),
123
+ "filename": filename,
124
+ "mime_type": mime or "application/octet-stream",
125
+ "size_bytes": len(data),
126
+ "hint": "用 Read 工具打开 local_path 查看内容(图片可直接看到画面)",
127
+ },
128
+ }
129
+
130
+
131
+ __all__ = ["handle_download_attachment"]