diff --git a/docs/superpowers/plans/2026-03-30-workbench-v2.md b/docs/superpowers/plans/2026-03-30-workbench-v2.md
new file mode 100644
index 0000000..51fa8aa
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-30-workbench-v2.md
@@ -0,0 +1,1225 @@
+# Workbench Server v2 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Restructure workbench-server into a pip-installable package with server persistence (survives AI CLI restarts), simplified desktop-only layout, and AI-guided setup.
+
+**Architecture:** The existing monolithic `server.py` gets split into a proper Python package under `src/workbench/`. A `.server.json` file per project tracks running HTTP servers. On MCP startup, the server scans for live servers and reattaches. The scaffold HTML drops the terminal panel for a full-width display surface.
+
+**Tech Stack:** Python 3.10+, `mcp` (FastMCP), `aiohttp` (HTTP/WebSocket), `pytest`/`pytest-asyncio` for tests.
+
+**Spec:** `docs/superpowers/specs/2026-03-30-workbench-v2-design.md`
+
+**Existing code:** The current `server.py` (293 lines) and `scaffold.html` (142 lines) are the starting point. We're restructuring, not rewriting from scratch — the core logic (MCP tools, HTTP server, WebSocket broadcast) is preserved.
+
+---
+
+## File Structure
+
+```
+workbench-server/
+ pyproject.toml # Package config, entry point
+ README.md # Public-facing docs (rewritten)
+ INSTALL.md # AI-readable setup instructions
+ LICENSE # MIT
+ src/
+ workbench/
+ __init__.py # Version string
+ __main__.py # python -m workbench
+ cli.py # CLI: mcp, serve, list, help
+ server.py # MCP tools + HTTP/WS server management
+ project.py # Project dir management, logging, persistence
+ scaffold.html # HTML template (moved from root, simplified)
+ tests/
+ conftest.py # Shared fixtures
+ test_project.py # Project creation, logging, persistence
+ test_server.py # MCP tool tests
+
+# Old files to remove:
+ server.py # replaced by src/workbench/server.py
+ scaffold.html # replaced by src/workbench/scaffold.html
+ requirements.txt # replaced by pyproject.toml
+```
+
+---
+
+## Task 1: Package Scaffold + Move Files
+
+**Files:**
+- Create: `pyproject.toml`
+- Create: `LICENSE`
+- Create: `src/workbench/__init__.py`
+- Create: `src/workbench/__main__.py`
+- Remove: `requirements.txt`
+- Move: `server.py` → will be replaced in later tasks
+- Move: `scaffold.html` → will be replaced in later tasks
+
+- [ ] **Step 1: Create directory structure**
+
+```bash
+mkdir -p ~/bin/workbench-server/src/workbench
+mkdir -p ~/bin/workbench-server/tests
+```
+
+- [ ] **Step 2: Write pyproject.toml**
+
+```toml
+[build-system]
+requires = ["setuptools>=68.0"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "workbench-server"
+version = "0.2.0"
+description = "MCP server that lets AI CLIs build interactive web pages served over LAN"
+readme = "README.md"
+license = "MIT"
+requires-python = ">=3.10"
+dependencies = [
+ "mcp>=1.26.0",
+ "aiohttp>=3.9.0",
+]
+
+[project.scripts]
+workbench = "workbench.cli:main"
+```
+
+- [ ] **Step 3: Write __init__.py**
+
+```python
+"""Workbench — MCP server for AI-driven interactive web pages."""
+
+__version__ = "0.2.0"
+```
+
+- [ ] **Step 4: Write __main__.py**
+
+```python
+"""Allow running as `python -m workbench`."""
+
+from workbench.cli import main
+
+main()
+```
+
+- [ ] **Step 5: Write LICENSE (MIT)**
+
+- [ ] **Step 6: Back up old files, remove requirements.txt**
+
+```bash
+mkdir -p ~/bin/workbench-server/.backup
+cp ~/bin/workbench-server/server.py ~/bin/workbench-server/.backup/server.py.v1
+cp ~/bin/workbench-server/scaffold.html ~/bin/workbench-server/.backup/scaffold.html.v1
+rm ~/bin/workbench-server/requirements.txt
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd ~/bin/workbench-server
+git add pyproject.toml LICENSE src/ .backup/
+git rm requirements.txt
+git commit -m "feat: package scaffold — pyproject.toml, src/workbench/ layout"
+```
+
+---
+
+## Task 2: Project Management Module
+
+**Files:**
+- Create: `src/workbench/project.py`
+- Create: `tests/conftest.py`
+- Create: `tests/test_project.py`
+
+Handles project directory creation, session logging, log reading, listing, and the new `.server.json` persistence.
+
+- [ ] **Step 1: Write test file**
+
+```python
+# tests/test_project.py
+import json
+from workbench.project import (
+ create_project, project_exists, project_path,
+ append_log, read_log, list_projects, log_session_event,
+ write_server_info, read_server_info, clear_server_info,
+ WORKBENCH_DIR,
+)
+
+
+def test_create_project(tmp_workbench):
+ path = create_project("io102", "Heathkit IO-102", workbench_dir=tmp_workbench)
+ assert path.exists()
+ assert (path / "index.html").exists()
+ assert (path / "session.md").exists()
+ assert (path / "session.jsonl").exists()
+ assert (path / "cost-log.jsonl").exists()
+ assert (path / "state.json").exists()
+ assert (path / "assets").is_dir()
+ md = (path / "session.md").read_text()
+ assert "Heathkit IO-102" in md
+
+
+def test_create_project_idempotent(tmp_workbench):
+ create_project("io102", "First", workbench_dir=tmp_workbench)
+ append_log("io102", "test entry", workbench_dir=tmp_workbench)
+ create_project("io102", "Second", workbench_dir=tmp_workbench)
+ entries = read_log("io102", workbench_dir=tmp_workbench)
+ assert len(entries) == 1
+
+
+def test_project_exists(tmp_workbench):
+ assert not project_exists("io102", workbench_dir=tmp_workbench)
+ create_project("io102", "Test", workbench_dir=tmp_workbench)
+ assert project_exists("io102", workbench_dir=tmp_workbench)
+
+
+def test_append_and_read_log(tmp_workbench):
+ create_project("io102", "Test", workbench_dir=tmp_workbench)
+ append_log("io102", "R412 measured 1.05M", data={"ohms": 1050000}, workbench_dir=tmp_workbench)
+ append_log("io102", "R413 OK", workbench_dir=tmp_workbench)
+ entries = read_log("io102", tail=10, workbench_dir=tmp_workbench)
+ assert len(entries) == 2
+ assert entries[0]["entry"] == "R412 measured 1.05M"
+ assert entries[0]["ohms"] == 1050000
+
+
+def test_read_log_tail(tmp_workbench):
+ create_project("io102", "Test", workbench_dir=tmp_workbench)
+ for i in range(30):
+ append_log("io102", f"Entry {i}", workbench_dir=tmp_workbench)
+ entries = read_log("io102", tail=5, workbench_dir=tmp_workbench)
+ assert len(entries) == 5
+ assert entries[0]["entry"] == "Entry 25"
+
+
+def test_list_projects(tmp_workbench):
+ assert list_projects(workbench_dir=tmp_workbench) == []
+ create_project("io102", "First", workbench_dir=tmp_workbench)
+ create_project("psu-rebuild", "Second", workbench_dir=tmp_workbench)
+ projects = list_projects(workbench_dir=tmp_workbench)
+ assert [p["name"] for p in projects] == ["io102", "psu-rebuild"]
+
+
+def test_log_session_event(tmp_workbench):
+ create_project("io102", "Test", workbench_dir=tmp_workbench)
+ log_session_event("io102", "session_start", workbench_dir=tmp_workbench)
+ cost_log = (tmp_workbench / "io102" / "cost-log.jsonl").read_text().strip()
+ entry = json.loads(cost_log.split("\n")[-1])
+ assert entry["event"] == "session_start"
+
+
+def test_server_info_write_read_clear(tmp_workbench):
+ create_project("io102", "Test", workbench_dir=tmp_workbench)
+ write_server_info("io102", pid=12345, port=8070, workbench_dir=tmp_workbench)
+ info = read_server_info("io102", workbench_dir=tmp_workbench)
+ assert info is not None
+ assert info["pid"] == 12345
+ assert info["port"] == 8070
+ assert "started" in info
+
+ clear_server_info("io102", workbench_dir=tmp_workbench)
+ assert read_server_info("io102", workbench_dir=tmp_workbench) is None
+```
+
+- [ ] **Step 2: Write conftest.py**
+
+```python
+# tests/conftest.py
+import pytest
+
+
+@pytest.fixture
+def tmp_workbench(tmp_path):
+ """Provide a temporary workbench directory."""
+ wb = tmp_path / "workbench"
+ wb.mkdir()
+ return wb
+```
+
+- [ ] **Step 3: Run tests to verify they fail**
+
+```bash
+cd ~/bin/workbench-server
+pip install --break-system-packages -e .
+pip install --break-system-packages pytest pytest-asyncio
+pytest tests/test_project.py -v
+```
+
+Expected: ImportError.
+
+- [ ] **Step 4: Write project.py**
+
+```python
+"""Project directory management — create, log, read, list, server persistence."""
+
+from __future__ import annotations
+
+import json
+import os
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Optional
+
+WORKBENCH_DIR = Path.home() / "workbench"
+SCAFFOLD_HTML = Path(__file__).parent / "scaffold.html"
+
+
+def _now_iso() -> str:
+ return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
+
+
+def project_path(name: str, workbench_dir: Path = WORKBENCH_DIR) -> Path:
+ return workbench_dir / name
+
+
+def project_exists(name: str, workbench_dir: Path = WORKBENCH_DIR) -> bool:
+ return project_path(name, workbench_dir).is_dir()
+
+
+def create_project(
+ name: str,
+ title: str,
+ description: str = "",
+ workbench_dir: Path = WORKBENCH_DIR,
+) -> Path:
+ """Create a project directory with scaffold HTML and log files. Idempotent."""
+ pdir = project_path(name, workbench_dir)
+ pdir.mkdir(parents=True, exist_ok=True)
+ (pdir / "assets").mkdir(exist_ok=True)
+
+ # Write scaffold HTML (always overwrite — it's a template, not user content)
+ if SCAFFOLD_HTML.exists():
+ template = SCAFFOLD_HTML.read_text()
+ html = template.replace("{{TITLE}}", title)
+ html = html.replace("{{DESCRIPTION}}", description)
+ (pdir / "index.html").write_text(html)
+
+ # Only write log files if they don't exist (preserve existing session data)
+ md_path = pdir / "session.md"
+ if not md_path.exists():
+ md_path.write_text(f"# {title} — Session Log\n\nStarted: {_now_iso()}\n\n")
+
+ jsonl_path = pdir / "session.jsonl"
+ if not jsonl_path.exists():
+ jsonl_path.write_text("")
+
+ cost_path = pdir / "cost-log.jsonl"
+ if not cost_path.exists():
+ cost_path.write_text("")
+
+ state_path = pdir / "state.json"
+ if not state_path.exists():
+ state_path.write_text("{}")
+
+ return pdir
+
+
+def append_log(
+ name: str,
+ entry: str,
+ data: Optional[dict] = None,
+ workbench_dir: Path = WORKBENCH_DIR,
+) -> dict:
+ """Append to session.md and session.jsonl."""
+ pdir = project_path(name, workbench_dir)
+ ts = _now_iso()
+
+ with open(pdir / "session.md", "a") as f:
+ f.write(f"\n### {ts}\n{entry}\n")
+
+ obj = {"ts": ts, "entry": entry}
+ if data:
+ obj.update(data)
+ with open(pdir / "session.jsonl", "a") as f:
+ f.write(json.dumps(obj) + "\n")
+
+ return obj
+
+
+def read_log(
+ name: str, tail: int = 20, workbench_dir: Path = WORKBENCH_DIR
+) -> list[dict]:
+ """Read recent log entries from session.jsonl."""
+ jsonl_path = project_path(name, workbench_dir) / "session.jsonl"
+ if not jsonl_path.exists():
+ return []
+
+ lines = jsonl_path.read_text().strip().split("\n")
+ lines = [l for l in lines if l.strip()]
+ recent = lines[-tail:] if len(lines) > tail else lines
+
+ entries = []
+ for line in recent:
+ try:
+ entries.append(json.loads(line))
+ except json.JSONDecodeError:
+ entries.append({"raw": line})
+ return entries
+
+
+def list_projects(workbench_dir: Path = WORKBENCH_DIR) -> list[dict]:
+ """List all project directories."""
+ if not workbench_dir.exists():
+ return []
+ projects = []
+ for d in sorted(workbench_dir.iterdir()):
+ if d.is_dir() and not d.name.startswith("."):
+ projects.append({"name": d.name})
+ return projects
+
+
+def log_session_event(
+ name: str, event: str, workbench_dir: Path = WORKBENCH_DIR, **extra
+) -> None:
+ """Append to cost-log.jsonl."""
+ pdir = project_path(name, workbench_dir)
+ obj = {"ts": _now_iso(), "event": event, "project": name}
+ obj.update(extra)
+ with open(pdir / "cost-log.jsonl", "a") as f:
+ f.write(json.dumps(obj) + "\n")
+
+
+# --- Server persistence ---
+
+def write_server_info(
+ name: str, pid: int, port: int, workbench_dir: Path = WORKBENCH_DIR
+) -> None:
+ """Write .server.json with running server info."""
+ pdir = project_path(name, workbench_dir)
+ info = {"pid": pid, "port": port, "started": _now_iso()}
+ (pdir / ".server.json").write_text(json.dumps(info))
+
+
+def read_server_info(
+ name: str, workbench_dir: Path = WORKBENCH_DIR
+) -> Optional[dict]:
+ """Read .server.json. Returns None if not found."""
+ server_file = project_path(name, workbench_dir) / ".server.json"
+ if not server_file.exists():
+ return None
+ try:
+ return json.loads(server_file.read_text())
+ except (json.JSONDecodeError, OSError):
+ return None
+
+
+def clear_server_info(
+ name: str, workbench_dir: Path = WORKBENCH_DIR
+) -> None:
+ """Delete .server.json."""
+ server_file = project_path(name, workbench_dir) / ".server.json"
+ if server_file.exists():
+ server_file.unlink()
+```
+
+- [ ] **Step 5: Copy scaffold.html to src/workbench/**
+
+The scaffold HTML needs to exist at `src/workbench/scaffold.html` for `create_project` to find it. We'll write the v2 version in Task 3. For now, create a minimal placeholder so tests pass:
+
+```html
+
+
{{TITLE}}
+{{TITLE}}
{{DESCRIPTION}}
+```
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+```bash
+pytest tests/test_project.py -v
+```
+
+Expected: All 9 tests pass.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/workbench/project.py src/workbench/scaffold.html tests/conftest.py tests/test_project.py
+git commit -m "feat: project management module — create, log, read, list, server persistence"
+```
+
+---
+
+## Task 3: Scaffold HTML v2
+
+**Files:**
+- Create: `src/workbench/scaffold.html` (overwrite placeholder from Task 2)
+
+- [ ] **Step 1: Write the v2 scaffold HTML**
+
+Full-width desktop layout. No split pane, no iframe, no terminal embed. Same WebSocket reconnect and state handling as v1.
+
+```html
+
+
+
+
+
+{{TITLE}}
+
+
+
+
+
{{TITLE}}
+
{{DESCRIPTION}}
+
Waiting for content...
+
+
+ Connecting...
+
+
+
+
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/workbench/scaffold.html
+git commit -m "feat: scaffold HTML v2 — full-width desktop layout, no terminal panel"
+```
+
+---
+
+## Task 4: MCP Server with Persistence
+
+**Files:**
+- Create: `src/workbench/server.py`
+- Create: `tests/test_server.py`
+
+The core MCP server — same 6 tools as v1, but with persistence logic. Adapted from the original `server.py` with the `.server.json` check-and-reattach flow.
+
+- [ ] **Step 1: Write test file**
+
+```python
+# tests/test_server.py
+import asyncio
+import json
+import os
+from pathlib import Path
+from unittest.mock import patch, AsyncMock, MagicMock
+
+import pytest
+
+from workbench.server import WorkbenchServer
+
+
+@pytest.fixture
+def server(tmp_workbench):
+ return WorkbenchServer(workbench_dir=tmp_workbench)
+
+
+@pytest.mark.asyncio
+async def test_scaffold_creates_project(server, tmp_workbench):
+ with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070):
+ result = await server.workbench_scaffold("test-proj", "Test Project")
+ data = json.loads(result)
+ assert "url" in data
+ assert (tmp_workbench / "test-proj" / "index.html").exists()
+
+
+@pytest.mark.asyncio
+async def test_scaffold_reattaches_to_running_server(server, tmp_workbench):
+ """If .server.json exists and server is alive, don't start a new one."""
+ from workbench.project import create_project, write_server_info
+ create_project("test-proj", "Test", workbench_dir=tmp_workbench)
+ write_server_info("test-proj", pid=os.getpid(), port=9999, workbench_dir=tmp_workbench)
+
+ with patch.object(server, "_is_server_alive", return_value=True):
+ with patch.object(server, "_start_http_server", new_callable=AsyncMock) as mock_start:
+ result = await server.workbench_scaffold("test-proj", "Test")
+ mock_start.assert_not_called() # Should NOT start a new server
+ data = json.loads(result)
+ assert "9999" in data["url"]
+
+
+@pytest.mark.asyncio
+async def test_scaffold_replaces_dead_server(server, tmp_workbench):
+ """If .server.json exists but server is dead, start a new one."""
+ from workbench.project import create_project, write_server_info
+ create_project("test-proj", "Test", workbench_dir=tmp_workbench)
+ write_server_info("test-proj", pid=99999, port=9999, workbench_dir=tmp_workbench)
+
+ with patch.object(server, "_is_server_alive", return_value=False):
+ with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070):
+ result = await server.workbench_scaffold("test-proj", "Test")
+ data = json.loads(result)
+ assert "8070" in data["url"]
+
+
+@pytest.mark.asyncio
+async def test_list_empty(server):
+ result = await server.workbench_list()
+ data = json.loads(result)
+ assert data["projects"] == []
+
+
+@pytest.mark.asyncio
+async def test_log_writes_to_disk(server, tmp_workbench):
+ with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070):
+ await server.workbench_scaffold("test-proj", "Test")
+ result = await server.workbench_log("test-proj", "R412 measured 1.05M")
+ data = json.loads(result)
+ assert data["ok"] is True
+ jsonl = (tmp_workbench / "test-proj" / "session.jsonl").read_text().strip()
+ assert "R412 measured 1.05M" in jsonl
+
+
+@pytest.mark.asyncio
+async def test_state_saves_to_disk(server, tmp_workbench):
+ with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070):
+ await server.workbench_scaffold("test-proj", "Test")
+ state = json.dumps({"template": "Hello
"})
+ result = await server.workbench_state("test-proj", state)
+ data = json.loads(result)
+ assert data["ok"] is True
+ saved = json.loads((tmp_workbench / "test-proj" / "state.json").read_text())
+ assert saved["template"] == "Hello
"
+
+
+@pytest.mark.asyncio
+async def test_stop_cleans_up(server, tmp_workbench):
+ with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070):
+ await server.workbench_scaffold("test-proj", "Test")
+ server._runners["test-proj"] = AsyncMock()
+ result = await server.workbench_stop("test-proj")
+ data = json.loads(result)
+ assert data["ok"] is True
+ assert not (tmp_workbench / "test-proj" / ".server.json").exists()
+
+
+@pytest.mark.asyncio
+async def test_reconnect_on_startup(tmp_workbench):
+ """On init, server should find live servers from .server.json files."""
+ from workbench.project import create_project, write_server_info
+ create_project("live-proj", "Live", workbench_dir=tmp_workbench)
+ write_server_info("live-proj", pid=os.getpid(), port=8070, workbench_dir=tmp_workbench)
+ create_project("dead-proj", "Dead", workbench_dir=tmp_workbench)
+ write_server_info("dead-proj", pid=99999, port=8071, workbench_dir=tmp_workbench)
+
+ srv = WorkbenchServer(workbench_dir=tmp_workbench)
+ with patch.object(srv, "_is_server_alive", side_effect=lambda port: port == 8070):
+ await srv.reconnect_existing_servers()
+
+ assert "live-proj" in srv._active
+ assert srv._active["live-proj"]["port"] == 8070
+ assert "dead-proj" not in srv._active
+ # Dead server's .server.json should be cleaned up
+ assert not (tmp_workbench / "dead-proj" / ".server.json").exists()
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+```bash
+pytest tests/test_server.py -v
+```
+
+Expected: ImportError.
+
+- [ ] **Step 3: Write server.py**
+
+```python
+"""MCP server — 6 workbench tools with HTTP/WS server management and persistence."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import socket
+from pathlib import Path
+from typing import Optional
+
+from aiohttp import web
+from mcp.server.fastmcp import FastMCP
+
+from workbench.project import (
+ create_project, project_exists, project_path,
+ append_log, read_log, list_projects, log_session_event,
+ write_server_info, read_server_info, clear_server_info,
+ WORKBENCH_DIR,
+)
+
+
+def _get_lan_ip() -> str:
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ s.connect(("192.168.0.1", 80))
+ ip = s.getsockname()[0]
+ s.close()
+ return ip
+ except Exception:
+ return "127.0.0.1"
+
+
+def _find_free_port(start: int = 8070) -> int:
+ for port in range(start, start + 100):
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.bind(("0.0.0.0", port))
+ s.close()
+ return port
+ except OSError:
+ continue
+ raise RuntimeError(f"No free port found in range {start}-{start + 99}")
+
+
+class WorkbenchServer:
+ """Core server logic — testable without MCP transport."""
+
+ def __init__(self, workbench_dir: Path = WORKBENCH_DIR):
+ self.workbench_dir = Path(workbench_dir)
+ self._active: dict[str, dict] = {} # name -> {"port": int, "runner": AppRunner, "ws_clients": set}
+ self._runners: dict[str, web.AppRunner] = {}
+
+ def _is_server_alive(self, port: int) -> bool:
+ """Check if an HTTP server is responding on the given port."""
+ try:
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.settimeout(1)
+ s.connect(("127.0.0.1", port))
+ s.close()
+ return True
+ except (OSError, ConnectionRefusedError):
+ return False
+
+ async def reconnect_existing_servers(self) -> None:
+ """Scan for running servers from .server.json files. Called on MCP startup."""
+ if not self.workbench_dir.exists():
+ return
+ for d in self.workbench_dir.iterdir():
+ if not d.is_dir():
+ continue
+ name = d.name
+ info = read_server_info(name, workbench_dir=self.workbench_dir)
+ if info is None:
+ continue
+ port = info["port"]
+ if self._is_server_alive(port):
+ self._active[name] = {"port": port, "ws_clients": set()}
+ else:
+ clear_server_info(name, workbench_dir=self.workbench_dir)
+
+ async def _start_http_server(self, name: str) -> int:
+ """Start an HTTP + WebSocket server for a project. Returns port."""
+ port = _find_free_port()
+ pdir = project_path(name, self.workbench_dir)
+
+ app = web.Application()
+ app["project_name"] = name
+ app["workbench_server"] = self
+
+ async def ws_handler(request):
+ ws = web.WebSocketResponse()
+ await ws.prepare(request)
+ proj = request.app["project_name"]
+ if proj in self._active:
+ self._active[proj]["ws_clients"].add(ws)
+ try:
+ async for msg in ws:
+ pass
+ finally:
+ if proj in self._active:
+ self._active[proj]["ws_clients"].discard(ws)
+ return ws
+
+ async def static_handler(request):
+ proj = request.app["project_name"]
+ path = request.match_info.get("path", "index.html") or "index.html"
+ file_path = project_path(proj, self.workbench_dir) / path
+ if not file_path.exists():
+ return web.Response(status=404, text="Not found")
+ return web.FileResponse(file_path)
+
+ app.router.add_get("/ws", ws_handler)
+ app.router.add_get("/{path:.*}", static_handler)
+ app.router.add_get("/", static_handler)
+
+ runner = web.AppRunner(app)
+ await runner.setup()
+ site = web.TCPSite(runner, "0.0.0.0", port)
+ await site.start()
+
+ self._runners[name] = runner
+ self._active[name] = {"port": port, "ws_clients": set()}
+ write_server_info(name, pid=os.getpid(), port=port, workbench_dir=self.workbench_dir)
+
+ return port
+
+ async def _broadcast_ws(self, name: str, message: dict) -> None:
+ if name not in self._active:
+ return
+ clients = self._active[name]["ws_clients"]
+ dead = set()
+ for ws in clients:
+ try:
+ await ws.send_json(message)
+ except Exception:
+ dead.add(ws)
+ clients -= dead
+
+ # --- MCP Tool implementations ---
+
+ async def workbench_scaffold(self, name: str, title: str, description: str = "") -> str:
+ pdir = project_path(name, self.workbench_dir)
+ create_project(name, title, description, workbench_dir=self.workbench_dir)
+
+ # Check for existing running server
+ info = read_server_info(name, workbench_dir=self.workbench_dir)
+ if info and self._is_server_alive(info["port"]):
+ port = info["port"]
+ if name not in self._active:
+ self._active[name] = {"port": port, "ws_clients": set()}
+ else:
+ if info:
+ clear_server_info(name, workbench_dir=self.workbench_dir)
+ port = await self._start_http_server(name)
+
+ ip = _get_lan_ip()
+ log_session_event(name, "session_start", workbench_dir=self.workbench_dir)
+ return json.dumps({"path": str(pdir), "url": f"http://{ip}:{port}"})
+
+ async def workbench_state(self, project: str, state: str) -> str:
+ if not project_exists(project, workbench_dir=self.workbench_dir):
+ return json.dumps({"error": f"Project '{project}' not found. Run workbench_scaffold first."})
+
+ state_obj = json.loads(state)
+ pdir = project_path(project, self.workbench_dir)
+ (pdir / "state.json").write_text(json.dumps(state_obj, indent=2))
+
+ await self._broadcast_ws(project, {"type": "state", "state": state_obj})
+ return json.dumps({"ok": True})
+
+ async def workbench_log(self, project: str, entry: str, data: str = "{}") -> str:
+ if not project_exists(project, workbench_dir=self.workbench_dir):
+ return json.dumps({"error": f"Project '{project}' not found."})
+
+ data_obj = json.loads(data) if data and data != "{}" else None
+ append_log(project, entry, data=data_obj, workbench_dir=self.workbench_dir)
+ await self._broadcast_ws(project, {"type": "log", "entry": entry})
+ return json.dumps({"ok": True})
+
+ async def workbench_read_log(self, project: str, tail: int = 20) -> str:
+ if not project_exists(project, workbench_dir=self.workbench_dir):
+ return json.dumps({"error": f"Project '{project}' not found."})
+ entries = read_log(project, tail=tail, workbench_dir=self.workbench_dir)
+ return json.dumps({"entries": entries})
+
+ async def workbench_list(self) -> str:
+ projects = list_projects(workbench_dir=self.workbench_dir)
+ for p in projects:
+ p["active"] = p["name"] in self._active
+ if p["active"]:
+ ip = _get_lan_ip()
+ port = self._active[p["name"]]["port"]
+ p["url"] = f"http://{ip}:{port}"
+ return json.dumps({"projects": projects})
+
+ async def workbench_stop(self, project: str) -> str:
+ if project not in self._active:
+ return json.dumps({"error": f"Project '{project}' is not running."})
+
+ # Log session end
+ if project_exists(project, workbench_dir=self.workbench_dir):
+ entries = read_log(project, tail=999999, workbench_dir=self.workbench_dir)
+ log_session_event(
+ project, "session_end",
+ workbench_dir=self.workbench_dir,
+ log_entries=len(entries),
+ )
+
+ # Shutdown HTTP server
+ if project in self._runners:
+ await self._runners[project].cleanup()
+ del self._runners[project]
+
+ clear_server_info(project, workbench_dir=self.workbench_dir)
+ del self._active[project]
+ return json.dumps({"ok": True})
+
+
+def create_mcp_server(workbench_dir: Path = WORKBENCH_DIR) -> FastMCP:
+ """Create the FastMCP instance with all workbench tools registered."""
+ srv = WorkbenchServer(workbench_dir=workbench_dir)
+
+ mcp = FastMCP(
+ "workbench",
+ instructions="Workbench — build interactive web pages served over LAN. Call workbench_scaffold first.",
+ )
+
+ @mcp.tool()
+ async def workbench_scaffold(name: str, title: str, description: str = "") -> str:
+ """Create a workbench project and start the HTTP server. Returns the LAN URL to open in a browser. If the project already exists and its server is running, reattaches without starting a duplicate. Always safe to call — never creates duplicates."""
+ return await srv.workbench_scaffold(name, title, description)
+
+ @mcp.tool()
+ async def workbench_state(project: str, state: str) -> str:
+ """Push a state update to the browser via WebSocket. The state is a JSON string — include 'template' (HTML string) to replace the page content, 'styles' (CSS string) to inject styles, and 'script' (JS string) to execute code. The AI has full control over the page."""
+ return await srv.workbench_state(project, state)
+
+ @mcp.tool()
+ async def workbench_log(project: str, entry: str, data: str = "{}") -> str:
+ """Append a log entry to the session log. Shows in the browser log feed. entry: human-readable markdown string. data: optional JSON for the machine-readable log."""
+ return await srv.workbench_log(project, entry, data)
+
+ @mcp.tool()
+ async def workbench_read_log(project: str, tail: int = 20) -> str:
+ """Read recent session log entries so the AI can resume a previous session."""
+ return await srv.workbench_read_log(project, tail)
+
+ @mcp.tool()
+ async def workbench_list() -> str:
+ """List all workbench projects and whether their HTTP server is running."""
+ return await srv.workbench_list()
+
+ @mcp.tool()
+ async def workbench_stop(project: str) -> str:
+ """Stop the HTTP server for a project and end the session."""
+ return await srv.workbench_stop(project)
+
+ return mcp
+```
+
+- [ ] **Step 4: Run tests**
+
+```bash
+pytest tests/test_server.py -v
+```
+
+Expected: All 9 tests pass.
+
+- [ ] **Step 5: Run full suite**
+
+```bash
+pytest tests/ -v
+```
+
+Expected: All 18 tests pass (9 project + 9 server).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/workbench/server.py tests/test_server.py
+git commit -m "feat: MCP server with persistence — reattach to running servers on restart"
+```
+
+---
+
+## Task 5: CLI Entry Point
+
+**Files:**
+- Create: `src/workbench/cli.py`
+
+- [ ] **Step 1: Write cli.py**
+
+```python
+"""CLI entry point for workbench."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+import sys
+
+from workbench import __version__
+
+
+def cmd_mcp(args):
+ """Start the MCP server on stdio."""
+ from workbench.server import create_mcp_server
+ mcp = create_mcp_server()
+ mcp.run(transport="stdio")
+
+
+def cmd_serve(args):
+ """Serve a project without MCP."""
+ from workbench.server import WorkbenchServer
+
+ srv = WorkbenchServer()
+
+ async def run():
+ result = await srv.workbench_scaffold(args.name, args.name)
+ import json
+ data = json.loads(result)
+ print(f"Serving: {data['url']}")
+ try:
+ while True:
+ await asyncio.sleep(1)
+ except (KeyboardInterrupt, asyncio.CancelledError):
+ await srv.workbench_stop(args.name)
+
+ asyncio.run(run())
+
+
+def cmd_list(args):
+ """List all projects."""
+ from workbench.project import list_projects, read_server_info
+ projects = list_projects()
+ if not projects:
+ print("No projects found in ~/workbench/")
+ return
+ for p in projects:
+ info = read_server_info(p["name"])
+ status = f" (running on port {info['port']})" if info else ""
+ print(f" {p['name']}{status}")
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ prog="workbench",
+ description="MCP server for AI-driven interactive web pages",
+ )
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
+ sub = parser.add_subparsers(dest="command")
+
+ sub.add_parser("mcp", help="Start MCP server (stdio transport)")
+
+ serve_parser = sub.add_parser("serve", help="Serve a project (standalone)")
+ serve_parser.add_argument("name", help="Project name")
+
+ sub.add_parser("list", help="List all projects")
+ sub.add_parser("help", help="Show help")
+
+ args = parser.parse_args()
+
+ commands = {
+ "mcp": cmd_mcp,
+ "serve": cmd_serve,
+ "list": cmd_list,
+ "help": lambda a: parser.print_help(),
+ }
+
+ if args.command is None:
+ parser.print_help()
+ sys.exit(0)
+
+ commands[args.command](args)
+```
+
+- [ ] **Step 2: Install and verify**
+
+```bash
+cd ~/bin/workbench-server
+pip install --break-system-packages -e .
+workbench --version
+workbench help
+workbench list
+```
+
+Expected: version "workbench 0.2.0", help shows subcommands, list says "No projects found".
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/workbench/cli.py
+git commit -m "feat: CLI entry point — mcp, serve, list, help"
+```
+
+---
+
+## Task 6: Clean Up Old Files
+
+**Files:**
+- Remove: `server.py` (root level — replaced by `src/workbench/server.py`)
+- Remove: `scaffold.html` (root level — replaced by `src/workbench/scaffold.html`)
+
+- [ ] **Step 1: Remove old files**
+
+```bash
+cd ~/bin/workbench-server
+git rm server.py scaffold.html
+```
+
+- [ ] **Step 2: Run full test suite to confirm nothing breaks**
+
+```bash
+pytest tests/ -v
+```
+
+Expected: All 18 tests pass.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git commit -m "refactor: remove old root-level server.py and scaffold.html"
+```
+
+---
+
+## Task 7: Docs — README, INSTALL.md, LICENSE
+
+**Files:**
+- Rewrite: `README.md`
+- Create: `INSTALL.md`
+
+- [ ] **Step 1: Rewrite README.md**
+
+Public-facing. Desktop browser as display surface. Setup is "paste URL" or "read INSTALL.md". No mention of kitty or terminal splitting. Adapted from the kitty-workbench README pattern but for browser-based display.
+
+Key sections: what it does (with ASCII art diagram), setup (clone + "tell AI to read INSTALL.md"), tools table, usage examples, project structure, requirements, FAQ.
+
+- [ ] **Step 2: Write INSTALL.md**
+
+AI-readable setup instructions. Simpler than kitty-workbench (no terminal detection needed):
+
+0. Clone + pip install
+1. Detect environment: Python version, platform, browser availability
+2. Detect SSH usage: `$SSH_CONNECTION`, `~/.ssh/known_hosts`, ControlMaster config
+3. Present options: SSH ControlMaster if applicable
+4. Configure MCP for user's AI CLI (detect Claude Code, Gemini CLI, etc.)
+5. Smoke test: `workbench list`
+6. Write `~/workbench/START.md`
+
+Include SSH ControlMaster setup (same as kitty-workbench INSTALL.md — `ControlMaster auto`, `ControlPersist yes`, sockets dir).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add README.md INSTALL.md
+git commit -m "docs: README and INSTALL.md — AI-guided setup"
+```
+
+---
+
+## Task 8: Push to Gitea
+
+- [ ] **Step 1: Run full test suite**
+
+```bash
+cd ~/bin/workbench-server
+pytest tests/ -v
+```
+
+Expected: All 18 tests pass.
+
+- [ ] **Step 2: Push**
+
+```bash
+cd ~/bin/workbench-server
+gitea push
+```
+
+---
+
+## Summary
+
+| Task | What | Key files |
+|------|------|-----------|
+| 1 | Package scaffold + move files | pyproject.toml, __init__.py, __main__.py, LICENSE |
+| 2 | Project management module | project.py, test_project.py |
+| 3 | Scaffold HTML v2 | scaffold.html |
+| 4 | MCP server with persistence | server.py, test_server.py |
+| 5 | CLI entry point | cli.py |
+| 6 | Clean up old files | remove server.py, scaffold.html from root |
+| 7 | Docs | README.md, INSTALL.md |
+| 8 | Push to Gitea | — |