Add persistent live OpenCADStudio control

This commit is contained in:
Hakan Seven committed 2026-09-04 22:25:55 +03:00
1 parent 9d991d7feb
commit 28cc473062
22 files changed
+1764 -95

No files matched your search

+16 -62
View File
@@ -1,73 +1,27 @@
# Headless automation API
# OpenCADStudio automation
Open CAD Studio can run without a GUI and be driven over a line-based JSON
protocol — for scripts, batch jobs, or AI agents.
`--serve` remains available for isolated headless scripts. `live.py` connects to the real desktop application through a private per-process descriptor under the user's OCS configuration directory. The GUI owns every mutation: transport threads only validate and enqueue requests.
```sh
OpenCADStudio --serve # stdin/stdout transport
OpenCADStudio --serve --port 4242 # listen on 127.0.0.1:4242 instead
```
The live protocol requires protocol version 1, stable session/document IDs, an expected edit revision for mutations, and a unique request ID. Commands report `waiting_input` while interactive work remains. Asynchronous file and renderer work remains `running` until its real callback finishes. A bounded operation cache makes retries with the same payload idempotent; after a restart or an expired operation, clients must read state instead of replaying a mutation.
It reads one JSON request per line and writes one JSON response per line — over
**stdin/stdout**, or over a **local TCP socket** with `--port`. The active
document persists across requests (and, on the socket, across reconnects), so a
caller can act → observe → act.
Operations include document state and activation, paged entity queries, layer/header/property inspection, command discovery, start/run/step/cancel, WCS/UCS/relative points, entity and structure picks, selection, property edits, semantic UI actions, undo/redo, protected save, event polling, and current-window PNG capture. The status bar includes a control switch whose green, yellow, and red states mean ready, busy, and off.
## Protocol
The Web build exposes `ocs_control_submit(json)` and `ocs_control_take(ticket)`. They enter the same bounded GUI queue and return the same semantic responses; browser code polls the ticket until a response is present.
| Request | Response |
|---------|----------|
| `{"op":"new"}` | `{"ok":true,"total":0,"by_type":{}}` |
| `{"op":"open","path":"plan.dwg"}` | entity summary |
| `{"op":"run","cmd":"LAYER Walls"}` | `{"ok":true,"cmd":...,"entities":N,"added":D}` |
| `{"op":"entities"}` | `{"ok":true,"total":N,"by_type":{"Line":42,...}}` |
| `{"op":"query","type":"Line","layer":"Walls"}` | per-entity `{handle,type,layer,…geometry}` (Line/Circle/Arc/Point/Ellipse/Text/MText/Polyline/Insert; filters + `limit` optional) |
| `{"op":"layers"}` | layers `{name,color,off,frozen,locked}` + the current layer |
| `{"op":"header"}` | drawing variables (units, PDMODE/PDSIZE, LTSCALE, …) |
| `{"op":"select","handles":["2B"]}` | set the selection (by `handles`, `type`, or `layer`; `clear` to deselect) → `{"ok":true,"selected":N}` |
| `{"op":"undo"}` / `{"op":"redo"}` | step the document history → entity summary |
| `{"op":"save","path":"out.dwg"}` | `{"ok":true,"saved":"out.dwg"}` (path optional once opened/saved) |
Run `./docs/automation/install.sh` once. It installs the release binary and pinned MCP environment in `~/.local/share/ocs-control`, registers the `opencadstudio` stdio server in the user's Codex configuration, and leaves source-independent launch paths. The personal `opencadstudio-control` skill tells later Codex sessions how to use it safely.
Selection drives modify commands — `select` the targets (e.g. the handles a
`query` returned), then `run("ERASE")`:
```python
ocs.select(type="Line"); ocs.run("ERASE") # erase every line
ids = [e["handle"] for e in ocs.query(layer="Walls")["entities"]]
ocs.select(handles=ids); ocs.run("ERASE")
```
Every response has `"ok"`; failures carry `"error"`. `run` drives Open CAD
Studio's **own** command system — no separate bindings to maintain — so its
coverage grows with the app.
Interactive draw commands take their points as coordinate tokens; the tool is
started, the points are fed, and it is terminated as if Enter were pressed:
```
{"op":"run","cmd":"LINE 0,0 10,10 10,20"} → two Line segments
{"op":"run","cmd":"CIRCLE 5,5 3"} → centre 5,5 radius 3
```
Coordinates are `x,y` or `x,y,z`; `@dx,dy` is relative to the previous point.
Inline-argument commands (`PDMODE 3`, `LAYER Walls`) are passed through as-is.
> Coverage is growing: commands whose options are typed keywords or coordinates
> work; ones that still rely on on-screen picking (object selection by clicking)
> are being wired next.
## Python client
[`ocs.py`](ocs.py) is a ~100-line client — nothing to compile:
The legacy client is unchanged:
```python
from ocs import Ocs
with Ocs(binary="OpenCADStudio") as ocs: # spawns `--serve`
ocs.open("plan.dwg")
ocs.run("LAYER Walls")
print(ocs.entities())
ocs.save("plan_out.dwg")
with Ocs(binary="OpenCADStudio") as app:
app.new()
app.run("LINE 0,0 100,0")
```
Any language can speak the same protocol over a subprocess pipe.
For direct live diagnostics:
```sh
python3 docs/automation/live.py --launch
python3 docs/automation/live.py '{"op":"state"}'
```
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
DEST=${XDG_DATA_HOME:-"$HOME/.local/share"}/ocs-control
mkdir -p "$DEST"
cargo build --manifest-path "$ROOT/Cargo.toml" --release
cp "$ROOT/target/release/OpenCADStudio" "$DEST/OpenCADStudio.new"
chmod 755 "$DEST/OpenCADStudio.new"
mv -f "$DEST/OpenCADStudio.new" "$DEST/OpenCADStudio"
cp "$ROOT/docs/automation/live.py" "$DEST/live.py"
cp "$ROOT/docs/automation/mcp_server.py" "$DEST/mcp_server.py"
uv venv "$DEST/venv"
uv pip install --python "$DEST/venv/bin/python" -r "$ROOT/docs/automation/requirements-mcp.txt"
SKILLS=${CODEX_HOME:-"$HOME/.codex"}/skills
mkdir -p "$SKILLS/opencadstudio-control/agents"
cp "$ROOT/integrations/codex/opencadstudio-control/SKILL.md" "$SKILLS/opencadstudio-control/SKILL.md"
cp "$ROOT/integrations/codex/opencadstudio-control/agents/openai.yaml" "$SKILLS/opencadstudio-control/agents/openai.yaml"
codex mcp remove opencadstudio >/dev/null 2>&1 || true
codex mcp add opencadstudio -- "$DEST/venv/bin/python" "$DEST/mcp_server.py"
printf 'Installed OpenCADStudio and its persistent Codex MCP connection.\n'
+130
View File
@@ -0,0 +1,130 @@
"""Persistent local GUI client; no CAD implementation lives in this adapter."""
from __future__ import annotations
import json
import os
from pathlib import Path
import socket
import subprocess
import sys
import time
import uuid
from typing import Any
READ_OPS = {"state", "hello", "query", "entities", "layers", "header", "properties", "measure", "history", "commands", "events", "operation"}
def discovery_dir() -> Path:
if sys.platform == "win32":
base = Path(os.environ["APPDATA"])
elif sys.platform == "darwin":
base = Path.home() / "Library/Application Support"
else:
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return base / "OpenCADStudio/automation"
def exchange(descriptor: dict, request: dict, timeout: float = 15) -> dict:
payload = {**request, "token": descriptor["token"], "session_id": descriptor["session_id"], "protocol": 1}
wire = (json.dumps(payload, ensure_ascii=False) + "\n").encode()
if len(wire) > 1_048_576:
raise ValueError("Request exceeds 1 MiB")
with socket.create_connection(("127.0.0.1", descriptor["port"]), timeout=timeout) as sock:
sock.sendall(wire)
with sock.makefile("rb") as stream:
line = stream.readline(16 * 1024 * 1024 + 1)
if not line or len(line) > 16 * 1024 * 1024:
raise ConnectionError("No valid OCS response; query request_id before retrying a mutation")
return json.loads(line)
def sessions() -> list[dict]:
found = []
for path in sorted(discovery_dir().glob("*.json")):
descriptor = None
try:
if os.name == "posix" and (path.stat().st_uid != os.getuid() or path.stat().st_mode & 0o077):
continue
descriptor = json.loads(path.read_text())
state = exchange(descriptor, {"op": "hello"}, timeout=1)
if state.get("ok") and state.get("session_id") == descriptor["session_id"]:
found.append({"descriptor": descriptor, "state": state})
except (OSError, ValueError, KeyError):
# A crashed process cannot remove its descriptor. Only reap a file
# whose recorded PID is certainly gone; a busy live session keeps
# its discovery record for the next call.
try:
os.kill(int(descriptor["pid"]), 0)
except (ProcessLookupError, TypeError, KeyError):
try:
path.unlink()
except OSError:
pass
continue
return found
class LiveOcs:
def __init__(self, session_id: str | None = None):
available = sessions()
if session_id:
available = [s for s in available if s["state"]["session_id"] == session_id]
if len(available) != 1:
raise ValueError("Choose session_id from sessions; found " + str(len(available)))
self.descriptor = available[0]["descriptor"]
self.state = available[0]["state"]
self.client_id = uuid.uuid4().hex
def request(self, request: dict, wait_seconds: float = 30) -> dict:
req = dict(request)
if req.get("op") not in READ_OPS:
req.setdefault("request_id", uuid.uuid4().hex)
req.setdefault("client_id", self.client_id)
req.setdefault("document_id", self.state["document_id"])
req.setdefault("revision", self.state["revision"])
if req.get("op") in {"input", "property", "run", "action", "save", "undo", "redo"}:
req.setdefault("selection", self.state.get("selection", []))
response = exchange(self.descriptor, req)
deadline = time.monotonic() + min(max(wait_seconds, 0), 60)
while response.get("status") in ("accepted", "running") and time.monotonic() < deadline:
time.sleep(0.05)
response = exchange(self.descriptor, {"op": "operation", "request_id": req["request_id"]})
if "state" in response:
self.state = response["state"]
elif req.get("op") in ("hello", "state") and response.get("ok"):
self.state = response
return response
def launch(binary: str | None = None) -> list[dict]:
existing = sessions()
if existing:
return existing
executable = binary or os.environ.get("OCS_BINARY", str(Path.home() / ".local/share/ocs-control/OpenCADStudio"))
log_dir = Path.home() / ".local/share/ocs-control"
log_dir.mkdir(parents=True, exist_ok=True)
with (log_dir / "gui.log").open("ab") as log:
proc = subprocess.Popen([executable, "--new-instance"], stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
if proc.poll() is not None:
raise RuntimeError(f"OCS exited ({proc.returncode}); inspect {log_dir / 'gui.log'}")
available = sessions()
if available:
return available
time.sleep(0.2)
raise TimeoutError("OCS is still starting; call sessions again")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("request", nargs="?", help="JSON request; omit to list sessions")
parser.add_argument("--session")
parser.add_argument("--launch", action="store_true")
args = parser.parse_args()
if args.launch:
launch()
if args.request:
print(json.dumps(LiveOcs(args.session).request(json.loads(args.request)), ensure_ascii=False))
else:
print(json.dumps([s["state"] for s in sessions()], ensure_ascii=False))
+51
View File
@@ -0,0 +1,51 @@
"""Local stdio MCP adapter for a running OpenCADStudio GUI."""
from pathlib import Path
import tempfile
from typing import Any
from mcp.server.fastmcp import FastMCP, Image
from live import LiveOcs, READ_OPS, launch, sessions
mcp = FastMCP("OpenCADStudio", instructions="Use ocs_sessions, then ocs_read(state/commands) before editing. All changes target the actual GUI document. Preserve session_id, document_id and revisions. waiting_input means the command or dialog still needs input. Poll running operations; never replay a mutation with a new request_id after a timeout. Geometry is handled by OCS and its kernel.")
clients: dict[str, LiveOcs] = {}
def client(session_id: str) -> LiveOcs:
if session_id not in clients:
clients[session_id] = LiveOcs(session_id)
return clients[session_id]
@mcp.tool()
def ocs_sessions(launch_if_none: bool = True) -> list[dict[str, Any]]:
"""List real OCS GUI sessions and documents. Launch installed OCS if none is running."""
available = launch() if launch_if_none else sessions()
return [s["state"] for s in available]
@mcp.tool()
def ocs_read(session_id: str, op: str = "state", parameters: dict[str, Any] | None = None) -> dict[str, Any]:
"""Read state, commands/actions, query (type/layer/offset/limit), entities, layers, header, properties, measure(handles), history, events(after), operation(request_id)."""
if op not in READ_OPS:
raise ValueError("Use ocs_execute for mutations")
return client(session_id).request({**(parameters or {}), "op": op})
@mcp.tool()
def ocs_execute(session_id: str, request: dict[str, Any], wait_seconds: float = 30) -> dict[str, Any]:
"""Execute one semantic action. op: new/open(path)/activate(document_id)/start(cmd)/run(cmd)/input(kind,text or point)/select(handles)/property(field,value)/action(name)/save(path)/undo/redo/cancel. Input kinds: text, token, point(space wcs/ucs/relative), entity or structure(handle,point), selection, enter. Supply document_id and revision from state. request_id is the idempotency key. Accepted/running is not completion. Query operation after a timeout."""
return client(session_id).request(request, wait_seconds)
@mcp.tool()
def ocs_capture(session_id: str) -> Image:
"""Capture the actual current OCS window as PNG for visual verification."""
with tempfile.TemporaryDirectory(prefix="ocs-capture-") as directory:
path = Path(directory) / "window.png"
result = client(session_id).request({"op": "capture", "path": str(path)})
if not result.get("ok") or result.get("status") != "completed":
raise RuntimeError(str(result))
return Image(data=path.read_bytes(), format="png")
if __name__ == "__main__":
mcp.run(transport="stdio")
+19
View File
@@ -0,0 +1,19 @@
"""Protocol smoke test for the bundled MCP adapter."""
import asyncio
from pathlib import Path
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main() -> None:
server = Path(__file__).with_name("mcp_server.py")
params = StdioServerParameters(command=sys.executable, args=[str(server)])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
names = {tool.name for tool in tools.tools}
assert names == {"ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"}, names
if __name__ == "__main__":
asyncio.run(main())
+1
View File
@@ -0,0 +1 @@
mcp==1.29.1