connectonion-studio 0.1.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.
- connectonion_studio-0.1.0/LICENSE +21 -0
- connectonion_studio-0.1.0/PKG-INFO +129 -0
- connectonion_studio-0.1.0/README.md +102 -0
- connectonion_studio-0.1.0/co_studio/__init__.py +3 -0
- connectonion_studio-0.1.0/co_studio/__main__.py +27 -0
- connectonion_studio-0.1.0/co_studio/api/__init__.py +1 -0
- connectonion_studio-0.1.0/co_studio/api/agents.py +150 -0
- connectonion_studio-0.1.0/co_studio/api/settings_api.py +42 -0
- connectonion_studio-0.1.0/co_studio/api/setup.py +15 -0
- connectonion_studio-0.1.0/co_studio/api/ws.py +82 -0
- connectonion_studio-0.1.0/co_studio/app.py +92 -0
- connectonion_studio-0.1.0/co_studio/config.py +53 -0
- connectonion_studio-0.1.0/co_studio/creator.py +92 -0
- connectonion_studio-0.1.0/co_studio/diagnostics.py +62 -0
- connectonion_studio-0.1.0/co_studio/frontend/assets/onion/apple-touch-icon.png +0 -0
- connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_1_black.png +0 -0
- connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_2_purple.png +0 -0
- connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_3_white.png +0 -0
- connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_4_purple.png +0 -0
- connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_5_white.png +0 -0
- connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_6_purple.png +0 -0
- connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_7_white.png +0 -0
- connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_8_core.png +0 -0
- connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_full.png +0 -0
- connectonion_studio-0.1.0/co_studio/frontend/css/app.css +1108 -0
- connectonion_studio-0.1.0/co_studio/frontend/css/onion.css +174 -0
- connectonion_studio-0.1.0/co_studio/frontend/css/theme.css +153 -0
- connectonion_studio-0.1.0/co_studio/frontend/index.html +354 -0
- connectonion_studio-0.1.0/co_studio/frontend/js/api.js +67 -0
- connectonion_studio-0.1.0/co_studio/frontend/js/app.js +1376 -0
- connectonion_studio-0.1.0/co_studio/frontend/js/diagnostics.js +83 -0
- connectonion_studio-0.1.0/co_studio/frontend/js/onion.js +117 -0
- connectonion_studio-0.1.0/co_studio/frontend/js/ws.js +97 -0
- connectonion_studio-0.1.0/co_studio/identity.py +34 -0
- connectonion_studio-0.1.0/co_studio/logs.py +119 -0
- connectonion_studio-0.1.0/co_studio/ports.py +27 -0
- connectonion_studio-0.1.0/co_studio/registry.py +90 -0
- connectonion_studio-0.1.0/co_studio/runner/__init__.py +1 -0
- connectonion_studio-0.1.0/co_studio/runner/co_studio_runner.py +59 -0
- connectonion_studio-0.1.0/co_studio/setup_check.py +100 -0
- connectonion_studio-0.1.0/co_studio/storage.py +125 -0
- connectonion_studio-0.1.0/co_studio/supervisor.py +304 -0
- connectonion_studio-0.1.0/co_studio/templates/agent.py.tmpl +73 -0
- connectonion_studio-0.1.0/co_studio/toolkits/__init__.py +36 -0
- connectonion_studio-0.1.0/co_studio/toolkits/files.py +17 -0
- connectonion_studio-0.1.0/co_studio/toolkits/image.py +51 -0
- connectonion_studio-0.1.0/co_studio/toolkits/shell.py +19 -0
- connectonion_studio-0.1.0/co_studio/toolkits/utility.py +91 -0
- connectonion_studio-0.1.0/co_studio/toolkits/web.py +17 -0
- connectonion_studio-0.1.0/connectonion_studio.egg-info/PKG-INFO +129 -0
- connectonion_studio-0.1.0/connectonion_studio.egg-info/SOURCES.txt +55 -0
- connectonion_studio-0.1.0/connectonion_studio.egg-info/dependency_links.txt +1 -0
- connectonion_studio-0.1.0/connectonion_studio.egg-info/entry_points.txt +2 -0
- connectonion_studio-0.1.0/connectonion_studio.egg-info/requires.txt +8 -0
- connectonion_studio-0.1.0/connectonion_studio.egg-info/top_level.txt +1 -0
- connectonion_studio-0.1.0/pyproject.toml +50 -0
- connectonion_studio-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EvanYifanYang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: connectonion-studio
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ConnectOnion Studio — local test cockpit for ConnectOnion agents: create, run, QR, live logs, Copy-for-Claude diagnostics.
|
|
5
|
+
Author-email: EvanYifanYang <209575631+EvanYifanYang@users.noreply.github.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/EvanYifanYang/connectonion-studio
|
|
8
|
+
Project-URL: Repository, https://github.com/EvanYifanYang/connectonion-studio
|
|
9
|
+
Project-URL: Issues, https://github.com/EvanYifanYang/connectonion-studio/issues
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: fastapi>=0.110
|
|
19
|
+
Requires-Dist: uvicorn[standard]>=0.29
|
|
20
|
+
Requires-Dist: segno>=1.6
|
|
21
|
+
Requires-Dist: pydantic>=2
|
|
22
|
+
Requires-Dist: ifaddr>=0.2
|
|
23
|
+
Requires-Dist: psutil>=5.9
|
|
24
|
+
Requires-Dist: filelock>=3.12
|
|
25
|
+
Requires-Dist: connectonion>=1.2.1
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
<div align="center">
|
|
29
|
+
|
|
30
|
+
<img src="https://raw.githubusercontent.com/EvanYifanYang/connectonion-studio/main/co_studio/frontend/assets/onion/onion_full.png" width="96" alt="ConnectOnion Studio" />
|
|
31
|
+
|
|
32
|
+
# ConnectOnion Studio
|
|
33
|
+
|
|
34
|
+
**A local test cockpit for [ConnectOnion](https://github.com/openonion/connectonion) agents.**
|
|
35
|
+
|
|
36
|
+
Click toolkits to build an agent · scan its QR from the iOS app · watch live logs · copy a one-click diagnostics bundle straight into Claude.
|
|
37
|
+
|
|
38
|
+
[](https://pypi.org/project/connectonion-studio/)
|
|
39
|
+
[](https://www.python.org/)
|
|
40
|
+
[](#requirements)
|
|
41
|
+
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pipx install connectonion-studio # or: pip install connectonion-studio
|
|
50
|
+
co-studio # opens http://127.0.0.1:9900
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
That's it. The first launch **auto-creates your identity and activates your managed model key** (needs internet — it's your own key, one per user). Offline on first run? Just run `co auth` once you're back online.
|
|
54
|
+
|
|
55
|
+
## What it does
|
|
56
|
+
|
|
57
|
+
| | |
|
|
58
|
+
|---|---|
|
|
59
|
+
| 🧩 **Create** | Form: name · model · toolkit checkboxes → identity + QR ready instantly |
|
|
60
|
+
| ▶️ **Run** | One process per agent, health-polled every 5 s, live state over WebSocket |
|
|
61
|
+
| 📱 **QR** | Bare `0x` address — scan it in the iOS "Add agent" flow |
|
|
62
|
+
| 📜 **Logs** | Live stdout + framework-logger streams, per agent |
|
|
63
|
+
| 🩺 **Copy for Claude** | Paste-ready markdown diagnostics bundle, one click |
|
|
64
|
+
| 🗑️ **Delete** | Moves to `~/.co-studio/trash/` — identity keys are never hard-deleted |
|
|
65
|
+
|
|
66
|
+
**Toolkits:** `utility` · `web` · `files` · `shell` (approval-gated) · `image`
|
|
67
|
+
|
|
68
|
+
## Requirements
|
|
69
|
+
|
|
70
|
+
**Python 3.11+** · macOS, Linux, or Windows (WSL recommended)
|
|
71
|
+
|
|
72
|
+
<details>
|
|
73
|
+
<summary><b>API reference</b></summary>
|
|
74
|
+
|
|
75
|
+
<br>
|
|
76
|
+
|
|
77
|
+
| Method | Path | Returns |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `GET` | `/api/agents` | `{"agents": [AgentSummary]}` |
|
|
80
|
+
| `POST` | `/api/agents` | AgentDetail — body `{name, model, toolkits, trust}` |
|
|
81
|
+
| `GET` | `/api/agents/{slug}` | AgentDetail |
|
|
82
|
+
| `POST` | `/api/agents/{slug}/start` · `/stop` · `/restart` | `{"state": …}` |
|
|
83
|
+
| `POST` | `/api/agents/{slug}/rename` | AgentSummary — body `{name}` |
|
|
84
|
+
| `DELETE` | `/api/agents/{slug}` | `204` (moved to trash) |
|
|
85
|
+
| `GET` | `/api/agents/{slug}/qr.svg` | SVG QR |
|
|
86
|
+
| `GET` | `/api/agents/{slug}/diagnostics` | Markdown bundle |
|
|
87
|
+
| `GET` | `/api/setup/status` | Doctor checks |
|
|
88
|
+
| `WS` | `/ws/status` | Status frames (each change + every 5 s) |
|
|
89
|
+
| `WS` | `/ws/agents/{slug}/logs` | `{"source": "stdout" \| "logger", "line": …}` |
|
|
90
|
+
|
|
91
|
+
</details>
|
|
92
|
+
|
|
93
|
+
<details>
|
|
94
|
+
<summary><b>Storage & internals</b></summary>
|
|
95
|
+
|
|
96
|
+
<br>
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
~/.co-studio/agents/<slug>/ meta.json · agent.py · .env · .co/ · studio-stdout.log
|
|
100
|
+
~/.co-studio/trash/ deleted agents (timestamped)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
- Agent ports `8000–8099`, allocated by socket probe (busy ports skipped, re-probed at start).
|
|
104
|
+
- `agent.py` is standalone — eject it anywhere and `python agent.py` still works (the announce IP-trim patch is inlined).
|
|
105
|
+
|
|
106
|
+
</details>
|
|
107
|
+
|
|
108
|
+
<details>
|
|
109
|
+
<summary><b>Development (from source)</b></summary>
|
|
110
|
+
|
|
111
|
+
<br>
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
git clone https://github.com/EvanYifanYang/connectonion-studio && cd connectonion-studio
|
|
115
|
+
./install.sh
|
|
116
|
+
.venv/bin/co-studio
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
| Env | Effect |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `CONNECTONION_PATH=~/repo/connectonion ./install.sh` | Install the framework from a local checkout instead of PyPI |
|
|
122
|
+
| `PYTHON=python3.12 ./install.sh` | Pick the interpreter |
|
|
123
|
+
|
|
124
|
+
</details>
|
|
125
|
+
|
|
126
|
+
## Security
|
|
127
|
+
|
|
128
|
+
- The manager binds **`127.0.0.1:9900` only**.
|
|
129
|
+
- Agent ports are **unauthenticated** and exposed on your LAN by `host()` — that is how the phone connects. **Don't run on hostile networks.**
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="https://raw.githubusercontent.com/EvanYifanYang/connectonion-studio/main/co_studio/frontend/assets/onion/onion_full.png" width="96" alt="ConnectOnion Studio" />
|
|
4
|
+
|
|
5
|
+
# ConnectOnion Studio
|
|
6
|
+
|
|
7
|
+
**A local test cockpit for [ConnectOnion](https://github.com/openonion/connectonion) agents.**
|
|
8
|
+
|
|
9
|
+
Click toolkits to build an agent · scan its QR from the iOS app · watch live logs · copy a one-click diagnostics bundle straight into Claude.
|
|
10
|
+
|
|
11
|
+
[](https://pypi.org/project/connectonion-studio/)
|
|
12
|
+
[](https://www.python.org/)
|
|
13
|
+
[](#requirements)
|
|
14
|
+
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pipx install connectonion-studio # or: pip install connectonion-studio
|
|
23
|
+
co-studio # opens http://127.0.0.1:9900
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
That's it. The first launch **auto-creates your identity and activates your managed model key** (needs internet — it's your own key, one per user). Offline on first run? Just run `co auth` once you're back online.
|
|
27
|
+
|
|
28
|
+
## What it does
|
|
29
|
+
|
|
30
|
+
| | |
|
|
31
|
+
|---|---|
|
|
32
|
+
| 🧩 **Create** | Form: name · model · toolkit checkboxes → identity + QR ready instantly |
|
|
33
|
+
| ▶️ **Run** | One process per agent, health-polled every 5 s, live state over WebSocket |
|
|
34
|
+
| 📱 **QR** | Bare `0x` address — scan it in the iOS "Add agent" flow |
|
|
35
|
+
| 📜 **Logs** | Live stdout + framework-logger streams, per agent |
|
|
36
|
+
| 🩺 **Copy for Claude** | Paste-ready markdown diagnostics bundle, one click |
|
|
37
|
+
| 🗑️ **Delete** | Moves to `~/.co-studio/trash/` — identity keys are never hard-deleted |
|
|
38
|
+
|
|
39
|
+
**Toolkits:** `utility` · `web` · `files` · `shell` (approval-gated) · `image`
|
|
40
|
+
|
|
41
|
+
## Requirements
|
|
42
|
+
|
|
43
|
+
**Python 3.11+** · macOS, Linux, or Windows (WSL recommended)
|
|
44
|
+
|
|
45
|
+
<details>
|
|
46
|
+
<summary><b>API reference</b></summary>
|
|
47
|
+
|
|
48
|
+
<br>
|
|
49
|
+
|
|
50
|
+
| Method | Path | Returns |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `GET` | `/api/agents` | `{"agents": [AgentSummary]}` |
|
|
53
|
+
| `POST` | `/api/agents` | AgentDetail — body `{name, model, toolkits, trust}` |
|
|
54
|
+
| `GET` | `/api/agents/{slug}` | AgentDetail |
|
|
55
|
+
| `POST` | `/api/agents/{slug}/start` · `/stop` · `/restart` | `{"state": …}` |
|
|
56
|
+
| `POST` | `/api/agents/{slug}/rename` | AgentSummary — body `{name}` |
|
|
57
|
+
| `DELETE` | `/api/agents/{slug}` | `204` (moved to trash) |
|
|
58
|
+
| `GET` | `/api/agents/{slug}/qr.svg` | SVG QR |
|
|
59
|
+
| `GET` | `/api/agents/{slug}/diagnostics` | Markdown bundle |
|
|
60
|
+
| `GET` | `/api/setup/status` | Doctor checks |
|
|
61
|
+
| `WS` | `/ws/status` | Status frames (each change + every 5 s) |
|
|
62
|
+
| `WS` | `/ws/agents/{slug}/logs` | `{"source": "stdout" \| "logger", "line": …}` |
|
|
63
|
+
|
|
64
|
+
</details>
|
|
65
|
+
|
|
66
|
+
<details>
|
|
67
|
+
<summary><b>Storage & internals</b></summary>
|
|
68
|
+
|
|
69
|
+
<br>
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
~/.co-studio/agents/<slug>/ meta.json · agent.py · .env · .co/ · studio-stdout.log
|
|
73
|
+
~/.co-studio/trash/ deleted agents (timestamped)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- Agent ports `8000–8099`, allocated by socket probe (busy ports skipped, re-probed at start).
|
|
77
|
+
- `agent.py` is standalone — eject it anywhere and `python agent.py` still works (the announce IP-trim patch is inlined).
|
|
78
|
+
|
|
79
|
+
</details>
|
|
80
|
+
|
|
81
|
+
<details>
|
|
82
|
+
<summary><b>Development (from source)</b></summary>
|
|
83
|
+
|
|
84
|
+
<br>
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
git clone https://github.com/EvanYifanYang/connectonion-studio && cd connectonion-studio
|
|
88
|
+
./install.sh
|
|
89
|
+
.venv/bin/co-studio
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
| Env | Effect |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `CONNECTONION_PATH=~/repo/connectonion ./install.sh` | Install the framework from a local checkout instead of PyPI |
|
|
95
|
+
| `PYTHON=python3.12 ./install.sh` | Pick the interpreter |
|
|
96
|
+
|
|
97
|
+
</details>
|
|
98
|
+
|
|
99
|
+
## Security
|
|
100
|
+
|
|
101
|
+
- The manager binds **`127.0.0.1:9900` only**.
|
|
102
|
+
- Agent ports are **unauthenticated** and exposed on your LAN by `host()` — that is how the phone connects. **Don't run on hostile networks.**
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Entry point: serve on 127.0.0.1:9900 and open the dashboard in the browser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import threading
|
|
7
|
+
import webbrowser
|
|
8
|
+
|
|
9
|
+
import uvicorn
|
|
10
|
+
|
|
11
|
+
from . import config
|
|
12
|
+
from .app import create_app
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None) -> None:
|
|
16
|
+
"""Start the loopback-only studio server; pop the dashboard unless --no-browser."""
|
|
17
|
+
parser = argparse.ArgumentParser(prog="co-studio", description="ConnectOnion Studio — local agent test cockpit.")
|
|
18
|
+
parser.add_argument("--no-browser", action="store_true", help="do not open the dashboard in a browser (headless/CI)")
|
|
19
|
+
args = parser.parse_args(argv)
|
|
20
|
+
url = f"http://{config.STUDIO_HOST}:{config.STUDIO_PORT}"
|
|
21
|
+
if not args.no_browser:
|
|
22
|
+
threading.Timer(1.2, webbrowser.open, args=(url,)).start()
|
|
23
|
+
uvicorn.run(create_app(), host=config.STUDIO_HOST, port=config.STUDIO_PORT, log_level="info")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if __name__ == "__main__":
|
|
27
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""REST and WebSocket route modules."""
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""REST endpoints for agent CRUD, lifecycle, QR, and diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException, Response
|
|
6
|
+
from fastapi.responses import PlainTextResponse
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from .. import config, creator, diagnostics, identity, logs, registry
|
|
10
|
+
from ..registry import AgentMeta
|
|
11
|
+
from ..supervisor import SUPERVISOR
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/agents", tags=["agents"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CreateAgentBody(BaseModel):
|
|
17
|
+
"""POST /api/agents payload."""
|
|
18
|
+
|
|
19
|
+
name: str = Field(min_length=1, max_length=80)
|
|
20
|
+
model: str = "co/gemini-2.5-flash"
|
|
21
|
+
toolkits: list[str] = ["utility"]
|
|
22
|
+
trust: str = "open"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def summarize(meta: AgentMeta) -> dict[str, object]:
|
|
26
|
+
"""AgentSummary per the API contract."""
|
|
27
|
+
return {
|
|
28
|
+
"slug": meta.slug,
|
|
29
|
+
"name": meta.name,
|
|
30
|
+
"address": meta.address,
|
|
31
|
+
"port": meta.port,
|
|
32
|
+
"model": meta.model,
|
|
33
|
+
"toolkits": meta.toolkits,
|
|
34
|
+
"trust": meta.trust,
|
|
35
|
+
"state": SUPERVISOR.state_of(meta.slug),
|
|
36
|
+
"created_at": meta.created_at,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def detail(meta: AgentMeta) -> dict[str, object]:
|
|
41
|
+
"""AgentDetail per the API contract (summary + paths + runtime signals)."""
|
|
42
|
+
agent_dir = registry.agent_dir(meta.slug)
|
|
43
|
+
endpoints, relay = logs.parse_runtime_signals(agent_dir / config.STDOUT_LOG_NAME)
|
|
44
|
+
return {
|
|
45
|
+
**summarize(meta),
|
|
46
|
+
"script_path": str(agent_dir / "agent.py"),
|
|
47
|
+
"co_dir": str(agent_dir / ".co"),
|
|
48
|
+
"endpoints_announced": endpoints,
|
|
49
|
+
"relay_ok": relay,
|
|
50
|
+
"last_error": SUPERVISOR.last_error_of(meta.slug),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_meta(slug: str) -> AgentMeta:
|
|
55
|
+
"""Load meta or raise 404."""
|
|
56
|
+
meta = registry.load(slug)
|
|
57
|
+
if meta is None:
|
|
58
|
+
raise HTTPException(status_code=404, detail=f"no agent named '{slug}'")
|
|
59
|
+
return meta
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.get("")
|
|
63
|
+
def list_agents() -> dict[str, object]:
|
|
64
|
+
"""GET /api/agents."""
|
|
65
|
+
return {"agents": [summarize(meta) for meta in registry.load_all()]}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.post("")
|
|
69
|
+
def create_agent(body: CreateAgentBody) -> dict[str, object]:
|
|
70
|
+
"""Create identity + QR-ready agent directory; does NOT start the process."""
|
|
71
|
+
try:
|
|
72
|
+
meta = creator.create(body.name, body.model, body.toolkits, body.trust)
|
|
73
|
+
except ValueError as exc: # unknown toolkit or trust level
|
|
74
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
75
|
+
except RuntimeError as exc: # port range exhausted
|
|
76
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
77
|
+
return detail(meta)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.get("/{slug}")
|
|
81
|
+
def get_agent(slug: str) -> dict[str, object]:
|
|
82
|
+
"""GET /api/agents/{slug}."""
|
|
83
|
+
return detail(_get_meta(slug))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RenameBody(BaseModel):
|
|
87
|
+
"""POST /api/agents/{slug}/rename payload."""
|
|
88
|
+
|
|
89
|
+
name: str = Field(min_length=1, max_length=80)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.post("/{slug}/rename")
|
|
93
|
+
def rename_agent(slug: str, body: RenameBody) -> dict[str, object]:
|
|
94
|
+
"""Rename an agent: update meta.json AND re-render agent.py so the name it advertises
|
|
95
|
+
matches (keeps its slug, identity, port, and folder). Restart to apply to a live process."""
|
|
96
|
+
name = body.name.strip()
|
|
97
|
+
if not name:
|
|
98
|
+
raise HTTPException(status_code=422, detail="name must not be blank")
|
|
99
|
+
with registry.locked():
|
|
100
|
+
meta = _get_meta(slug)
|
|
101
|
+
meta.name = name
|
|
102
|
+
registry.save(meta)
|
|
103
|
+
agent_dir = registry.agent_dir(slug)
|
|
104
|
+
(agent_dir / "agent.py").write_text(
|
|
105
|
+
creator.render(name, meta.model, meta.port, meta.toolkits, meta.trust)
|
|
106
|
+
)
|
|
107
|
+
return summarize(meta)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.post("/{slug}/start")
|
|
111
|
+
async def start_agent(slug: str) -> dict[str, str]:
|
|
112
|
+
"""Spawn the agent process."""
|
|
113
|
+
return {"state": await SUPERVISOR.start(_get_meta(slug))}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@router.post("/{slug}/stop")
|
|
117
|
+
async def stop_agent(slug: str) -> dict[str, str]:
|
|
118
|
+
"""Terminate the agent process group."""
|
|
119
|
+
_get_meta(slug)
|
|
120
|
+
return {"state": await SUPERVISOR.stop(slug)}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.post("/{slug}/restart")
|
|
124
|
+
async def restart_agent(slug: str) -> dict[str, str]:
|
|
125
|
+
"""Stop then start the agent."""
|
|
126
|
+
return {"state": await SUPERVISOR.restart(_get_meta(slug))}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@router.delete("/{slug}", status_code=204)
|
|
130
|
+
async def delete_agent(slug: str) -> Response:
|
|
131
|
+
"""Stop the agent and permanently delete its directory (identity, keys, logs)."""
|
|
132
|
+
_get_meta(slug)
|
|
133
|
+
await SUPERVISOR.stop(slug)
|
|
134
|
+
SUPERVISOR.forget(slug)
|
|
135
|
+
with registry.locked():
|
|
136
|
+
registry.delete(slug)
|
|
137
|
+
return Response(status_code=204)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@router.get("/{slug}/qr.svg")
|
|
141
|
+
def agent_qr(slug: str) -> Response:
|
|
142
|
+
"""SVG QR of the bare 0x address for the iOS scan-to-add flow."""
|
|
143
|
+
meta = _get_meta(slug)
|
|
144
|
+
return Response(content=identity.qr_svg(meta.address), media_type="image/svg+xml")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@router.get("/{slug}/diagnostics")
|
|
148
|
+
def agent_diagnostics(slug: str) -> PlainTextResponse:
|
|
149
|
+
"""The Copy-for-Claude markdown bundle."""
|
|
150
|
+
return PlainTextResponse(diagnostics.build(_get_meta(slug)), media_type="text/markdown")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Studio settings: the agents storage location (with migration)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from .. import storage
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/settings", tags=["settings"])
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StorageBody(BaseModel):
|
|
14
|
+
"""POST /api/settings/storage payload."""
|
|
15
|
+
|
|
16
|
+
path: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("/storage")
|
|
20
|
+
def get_storage() -> dict[str, str]:
|
|
21
|
+
"""The agents directory in effect right now."""
|
|
22
|
+
return {"agents_dir": storage.current()}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.post("/pick-folder")
|
|
26
|
+
def pick_folder() -> dict[str, str]:
|
|
27
|
+
"""Pop the native folder chooser and return the chosen path."""
|
|
28
|
+
path = storage.pick_folder()
|
|
29
|
+
if not path:
|
|
30
|
+
raise HTTPException(status_code=409, detail="No folder chosen.")
|
|
31
|
+
return {"path": path}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.post("/storage")
|
|
35
|
+
async def set_storage(body: StorageBody) -> dict[str, object]:
|
|
36
|
+
"""Move existing agents to a new folder and switch the studio over to it."""
|
|
37
|
+
try:
|
|
38
|
+
return await storage.change(body.path)
|
|
39
|
+
except ValueError as exc: # bad/rejected path
|
|
40
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
41
|
+
except OSError as exc: # move failed mid-flight
|
|
42
|
+
raise HTTPException(status_code=500, detail=f"Move failed: {exc}") from exc
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Setup status endpoint (doctor + auth/key checks)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
|
|
7
|
+
from .. import setup_check
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/setup", tags=["setup"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/status")
|
|
13
|
+
def setup_status() -> dict[str, object]:
|
|
14
|
+
"""GET /api/setup/status."""
|
|
15
|
+
return setup_check.status()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""WebSocket endpoints: fleet status pushes and per-agent log streams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
11
|
+
|
|
12
|
+
from .. import config, logs, registry
|
|
13
|
+
from ..supervisor import SUPERVISOR
|
|
14
|
+
from .agents import summarize
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _status_frame() -> dict[str, object]:
|
|
20
|
+
"""The {"type":"status","agents":[...]} frame."""
|
|
21
|
+
return {"type": "status", "agents": [summarize(meta) for meta in registry.load_all()]}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.websocket("/ws/status")
|
|
25
|
+
async def status_ws(websocket: WebSocket) -> None:
|
|
26
|
+
"""Push a status frame on every state change and at least every 5 seconds."""
|
|
27
|
+
await websocket.accept()
|
|
28
|
+
queue = SUPERVISOR.subscribe()
|
|
29
|
+
try:
|
|
30
|
+
while True:
|
|
31
|
+
await websocket.send_json(_status_frame())
|
|
32
|
+
with suppress(asyncio.TimeoutError):
|
|
33
|
+
await asyncio.wait_for(queue.get(), timeout=config.HEALTH_INTERVAL)
|
|
34
|
+
except (WebSocketDisconnect, RuntimeError): # RuntimeError: send after client close
|
|
35
|
+
pass
|
|
36
|
+
finally:
|
|
37
|
+
SUPERVISOR.unsubscribe(queue)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@router.websocket("/ws/agents/{slug}/logs")
|
|
41
|
+
async def logs_ws(websocket: WebSocket, slug: str) -> None:
|
|
42
|
+
"""Stream stdout and framework-logger lines as they arrive."""
|
|
43
|
+
if registry.load(slug) is None:
|
|
44
|
+
await websocket.close(code=4404)
|
|
45
|
+
return
|
|
46
|
+
await websocket.accept()
|
|
47
|
+
agent_dir = registry.agent_dir(slug)
|
|
48
|
+
stdout_path = agent_dir / config.STDOUT_LOG_NAME
|
|
49
|
+
queue: asyncio.Queue[dict[str, str]] = asyncio.Queue()
|
|
50
|
+
|
|
51
|
+
async def pump(source: str, get_path: Callable[[], Path | None]) -> None:
|
|
52
|
+
async for line in logs.follow(get_path):
|
|
53
|
+
await queue.put({"source": source, "line": line})
|
|
54
|
+
|
|
55
|
+
async def send_loop() -> None:
|
|
56
|
+
while True:
|
|
57
|
+
await websocket.send_json(await queue.get())
|
|
58
|
+
|
|
59
|
+
async def recv_loop() -> None:
|
|
60
|
+
# Nothing else reads the socket, so an idle client's disconnect would never
|
|
61
|
+
# surface (send_loop is parked on queue.get()). Draining receives lets a close
|
|
62
|
+
# wake us so the pump tasks don't leak forever.
|
|
63
|
+
while True:
|
|
64
|
+
message = await websocket.receive()
|
|
65
|
+
if message.get("type") == "websocket.disconnect":
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
tasks = [
|
|
69
|
+
asyncio.create_task(pump("stdout", lambda: stdout_path)),
|
|
70
|
+
asyncio.create_task(pump("logger", lambda: logs.logger_log_path(agent_dir))),
|
|
71
|
+
asyncio.create_task(send_loop()),
|
|
72
|
+
asyncio.create_task(recv_loop()),
|
|
73
|
+
]
|
|
74
|
+
try:
|
|
75
|
+
done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
76
|
+
for task in done:
|
|
77
|
+
with suppress(WebSocketDisconnect, RuntimeError):
|
|
78
|
+
task.result() # absorb the expected disconnect/close; re-raise anything else
|
|
79
|
+
finally:
|
|
80
|
+
for task in tasks:
|
|
81
|
+
task.cancel()
|
|
82
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""FastAPI application factory: API routers, websockets, and the static frontend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from contextlib import asynccontextmanager, suppress
|
|
7
|
+
from typing import AsyncIterator
|
|
8
|
+
|
|
9
|
+
from fastapi import FastAPI
|
|
10
|
+
from fastapi.responses import FileResponse, HTMLResponse, Response
|
|
11
|
+
from fastapi.staticfiles import StaticFiles
|
|
12
|
+
|
|
13
|
+
from . import config, registry, setup_check
|
|
14
|
+
from .api import agents, settings_api, setup, ws
|
|
15
|
+
from .supervisor import SUPERVISOR
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _auto_authenticate() -> None:
|
|
19
|
+
"""Best-effort: mint THIS user's own managed key on first run, so no manual `co auth` is needed.
|
|
20
|
+
|
|
21
|
+
connectonion's auth is signature-based — no login/password/browser: it signs with the local
|
|
22
|
+
~/.co Ed25519 key and the backend auto-creates the account. The key is always PER-USER, never a
|
|
23
|
+
shared/bundled secret. Silent no-op on any failure (offline, backend down, or a refactor of these
|
|
24
|
+
CLI internals); the onboarding screen still covers those cases and re-polls /api/setup/status.
|
|
25
|
+
"""
|
|
26
|
+
if setup_check._has_managed_key(): # already activated → nothing to do
|
|
27
|
+
return
|
|
28
|
+
try:
|
|
29
|
+
from connectonion.cli.commands.auth_commands import authenticate
|
|
30
|
+
from connectonion.cli.commands.project_cmd_lib import ensure_global_config
|
|
31
|
+
|
|
32
|
+
if not (config.MAIN_CO_DIR / "keys" / "agent.key").exists():
|
|
33
|
+
ensure_global_config() # auth needs a keypair to sign — create the ~/.co identity first
|
|
34
|
+
ok = authenticate(config.MAIN_CO_DIR, save_to_project=False, quiet=True)
|
|
35
|
+
state = "ok — managed key ready" if ok else "skipped (offline?); run `co auth` when online"
|
|
36
|
+
print(f"[co-studio] auto-auth {state}", flush=True)
|
|
37
|
+
except Exception as exc: # noqa: BLE001 — never block startup on framework internals / network
|
|
38
|
+
print(f"[co-studio] auto-auth unavailable ({exc!r}) — run `co auth` manually if needed", flush=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
43
|
+
"""Run the doctor, kick off first-run auth, adopt orphan agents, and drive the health poll."""
|
|
44
|
+
registry.ensure_dirs()
|
|
45
|
+
# Background + best-effort so the server binds immediately; the key lands within a few seconds
|
|
46
|
+
# and the onboarding screen (2s poll) dismisses itself once /api/setup/status flips to key_ok.
|
|
47
|
+
auth_task = asyncio.create_task(asyncio.to_thread(_auto_authenticate))
|
|
48
|
+
for entry in setup_check.run_doctor():
|
|
49
|
+
mark = "ok " if entry["ok"] else "FAIL"
|
|
50
|
+
print(f"[co-studio] doctor {mark} {entry['check']} — {entry['detail']}", flush=True)
|
|
51
|
+
SUPERVISOR.adopt_orphans()
|
|
52
|
+
poller = asyncio.create_task(SUPERVISOR.run())
|
|
53
|
+
yield
|
|
54
|
+
poller.cancel()
|
|
55
|
+
auth_task.cancel()
|
|
56
|
+
for task in (poller, auth_task):
|
|
57
|
+
with suppress(asyncio.CancelledError, Exception):
|
|
58
|
+
await task
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_app() -> FastAPI:
|
|
62
|
+
"""Build the studio app: /api/*, /ws/*, and the zero-build frontend at /."""
|
|
63
|
+
app = FastAPI(title="ConnectOnion Studio", lifespan=_lifespan)
|
|
64
|
+
|
|
65
|
+
@app.middleware("http")
|
|
66
|
+
async def _always_revalidate(request, call_next):
|
|
67
|
+
"""No heuristic caching: the browser must revalidate every asset each load,
|
|
68
|
+
so edits to the zero-build frontend show up on a plain reload (still cheap —
|
|
69
|
+
unchanged files come back as a 304 via StaticFiles' ETag/Last-Modified)."""
|
|
70
|
+
response = await call_next(request)
|
|
71
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
72
|
+
return response
|
|
73
|
+
|
|
74
|
+
app.include_router(agents.router, prefix="/api")
|
|
75
|
+
app.include_router(setup.router, prefix="/api")
|
|
76
|
+
app.include_router(settings_api.router, prefix="/api")
|
|
77
|
+
app.include_router(ws.router)
|
|
78
|
+
|
|
79
|
+
for sub in ("assets", "css", "js"):
|
|
80
|
+
directory = config.FRONTEND_DIR / sub
|
|
81
|
+
if directory.is_dir():
|
|
82
|
+
app.mount(f"/{sub}", StaticFiles(directory=directory), name=sub)
|
|
83
|
+
|
|
84
|
+
@app.get("/", include_in_schema=False)
|
|
85
|
+
def index() -> Response:
|
|
86
|
+
"""Serve the SPA (or a plain notice while the frontend is absent)."""
|
|
87
|
+
page = config.FRONTEND_DIR / "index.html"
|
|
88
|
+
if page.exists():
|
|
89
|
+
return FileResponse(page)
|
|
90
|
+
return HTMLResponse("<h1>ConnectOnion Studio</h1><p>frontend/ not built yet — the API is live under /api.</p>")
|
|
91
|
+
|
|
92
|
+
return app
|