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.
Files changed (57) hide show
  1. connectonion_studio-0.1.0/LICENSE +21 -0
  2. connectonion_studio-0.1.0/PKG-INFO +129 -0
  3. connectonion_studio-0.1.0/README.md +102 -0
  4. connectonion_studio-0.1.0/co_studio/__init__.py +3 -0
  5. connectonion_studio-0.1.0/co_studio/__main__.py +27 -0
  6. connectonion_studio-0.1.0/co_studio/api/__init__.py +1 -0
  7. connectonion_studio-0.1.0/co_studio/api/agents.py +150 -0
  8. connectonion_studio-0.1.0/co_studio/api/settings_api.py +42 -0
  9. connectonion_studio-0.1.0/co_studio/api/setup.py +15 -0
  10. connectonion_studio-0.1.0/co_studio/api/ws.py +82 -0
  11. connectonion_studio-0.1.0/co_studio/app.py +92 -0
  12. connectonion_studio-0.1.0/co_studio/config.py +53 -0
  13. connectonion_studio-0.1.0/co_studio/creator.py +92 -0
  14. connectonion_studio-0.1.0/co_studio/diagnostics.py +62 -0
  15. connectonion_studio-0.1.0/co_studio/frontend/assets/onion/apple-touch-icon.png +0 -0
  16. connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_1_black.png +0 -0
  17. connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_2_purple.png +0 -0
  18. connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_3_white.png +0 -0
  19. connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_4_purple.png +0 -0
  20. connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_5_white.png +0 -0
  21. connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_6_purple.png +0 -0
  22. connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_7_white.png +0 -0
  23. connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_8_core.png +0 -0
  24. connectonion_studio-0.1.0/co_studio/frontend/assets/onion/onion_full.png +0 -0
  25. connectonion_studio-0.1.0/co_studio/frontend/css/app.css +1108 -0
  26. connectonion_studio-0.1.0/co_studio/frontend/css/onion.css +174 -0
  27. connectonion_studio-0.1.0/co_studio/frontend/css/theme.css +153 -0
  28. connectonion_studio-0.1.0/co_studio/frontend/index.html +354 -0
  29. connectonion_studio-0.1.0/co_studio/frontend/js/api.js +67 -0
  30. connectonion_studio-0.1.0/co_studio/frontend/js/app.js +1376 -0
  31. connectonion_studio-0.1.0/co_studio/frontend/js/diagnostics.js +83 -0
  32. connectonion_studio-0.1.0/co_studio/frontend/js/onion.js +117 -0
  33. connectonion_studio-0.1.0/co_studio/frontend/js/ws.js +97 -0
  34. connectonion_studio-0.1.0/co_studio/identity.py +34 -0
  35. connectonion_studio-0.1.0/co_studio/logs.py +119 -0
  36. connectonion_studio-0.1.0/co_studio/ports.py +27 -0
  37. connectonion_studio-0.1.0/co_studio/registry.py +90 -0
  38. connectonion_studio-0.1.0/co_studio/runner/__init__.py +1 -0
  39. connectonion_studio-0.1.0/co_studio/runner/co_studio_runner.py +59 -0
  40. connectonion_studio-0.1.0/co_studio/setup_check.py +100 -0
  41. connectonion_studio-0.1.0/co_studio/storage.py +125 -0
  42. connectonion_studio-0.1.0/co_studio/supervisor.py +304 -0
  43. connectonion_studio-0.1.0/co_studio/templates/agent.py.tmpl +73 -0
  44. connectonion_studio-0.1.0/co_studio/toolkits/__init__.py +36 -0
  45. connectonion_studio-0.1.0/co_studio/toolkits/files.py +17 -0
  46. connectonion_studio-0.1.0/co_studio/toolkits/image.py +51 -0
  47. connectonion_studio-0.1.0/co_studio/toolkits/shell.py +19 -0
  48. connectonion_studio-0.1.0/co_studio/toolkits/utility.py +91 -0
  49. connectonion_studio-0.1.0/co_studio/toolkits/web.py +17 -0
  50. connectonion_studio-0.1.0/connectonion_studio.egg-info/PKG-INFO +129 -0
  51. connectonion_studio-0.1.0/connectonion_studio.egg-info/SOURCES.txt +55 -0
  52. connectonion_studio-0.1.0/connectonion_studio.egg-info/dependency_links.txt +1 -0
  53. connectonion_studio-0.1.0/connectonion_studio.egg-info/entry_points.txt +2 -0
  54. connectonion_studio-0.1.0/connectonion_studio.egg-info/requires.txt +8 -0
  55. connectonion_studio-0.1.0/connectonion_studio.egg-info/top_level.txt +1 -0
  56. connectonion_studio-0.1.0/pyproject.toml +50 -0
  57. 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
+ [![PyPI](https://img.shields.io/pypi/v/connectonion-studio.svg)](https://pypi.org/project/connectonion-studio/)
39
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/)
40
+ [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey.svg)](#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 &amp; 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
+ [![PyPI](https://img.shields.io/pypi/v/connectonion-studio.svg)](https://pypi.org/project/connectonion-studio/)
12
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/)
13
+ [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey.svg)](#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 &amp; 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,3 @@
1
+ """ConnectOnion Studio — local test cockpit for ConnectOnion agents."""
2
+
3
+ __version__ = "0.1.0"
@@ -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