diff --git a/.dockerignore b/.dockerignore index 59bc06c..1c09996 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,5 @@ .git/ .venv/ -.entire/ .claude/ .secret.* *.wormhole @@ -14,4 +13,4 @@ dist/ build/ *.db .env -docs/plans/ +docs/ diff --git a/README.md b/README.md index 5225020..e0b2083 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Sessions auto-transition based on inactivity: `active → idle → suspended → |---|---|---| | `WARREN_ADMIN_TOKEN` | (required) | Token for admin operations (pairing, config) | | `WARREN_INTERNAL_SECRET` | (recommended) | Token required of agents connecting to `/api/agent/ws` | -| `WARREN_BACKEND` | `websocket` | Backend adapter: `websocket` or `orca` | +| `WARREN_BACKEND` | `websocket` | Backend adapter: `websocket` (default) or `orca` (experimental, single local terminal) | | `WARREN_CONFIG` | `config.yaml` | Path to YAML config file | | `WARREN_DB` | `warren.db` | Path to SQLite database | | `WARREN_HOST` | `0.0.0.0` | Server bind address | @@ -139,7 +139,7 @@ uv run pytest tests/ -v # Unit tests |---|---| | `server/src/warren/app.py` | FastAPI app, endpoints, SSE streaming | | `server/src/warren/adapters/websocket.py` | WebSocket agent proxy adapter (default) | -| `server/src/warren/adapters/orca.py` | Local Orca terminal adapter | +| `server/src/warren/adapters/orca.py` | Local Orca terminal adapter (experimental, single-session) | | `server/src/warren/voice.py` | Voice WebSocket handler (OpenClaw protocol) | | `server/src/warren/bus.py` | SessionBus pub/sub (multi-consumer events) | | `server/src/warren/lifecycle.py` | Background session lifecycle sweep | diff --git a/creation/index.html b/creation/index.html index f033835..268872d 100644 --- a/creation/index.html +++ b/creation/index.html @@ -49,8 +49,6 @@ NEW SESSION
- -
@@ -75,10 +73,6 @@
AGENTS
-
-
TASKS
-
-
- -``` - -**Step 2: Update app.js — remove sessionName references** - -In `creation/js/app.js`: - -1. Remove the `sessionName` DOM ref (line 29: `const sessionName = document.getElementById("session-name");`) - -2. Update `startSession()` — remove `sessionName.value.trim()` and pass `undefined` for name: - -```javascript - async function startSession() { - const repo = repoSelect.value; - const msg = initialMsg.value.trim(); - if (!repo || !msg) return; - - try { - const data = await Warren.createSession(repo, msg); - initialMsg.value = ""; - openSession({ id: data.session_id, name: data.name, repo: data.repo }); - } catch (err) { - appendMsg("msg-error", "Failed to create session"); - } - } -``` - -3. Update `loadRepos()` — auto-select and skip to prompt if only one repo: - -```javascript - async function loadRepos() { - try { - const repos = await Warren.fetchRepos(); - const names = Object.keys(repos); - repoSelect.textContent = ""; - names.forEach((name) => { - const opt = document.createElement("option"); - opt.value = name; - opt.textContent = name; - repoSelect.appendChild(opt); - }); - // Auto-hide repo picker if only one repo - const repoLabel = repoSelect.previousElementSibling; - if (names.length <= 1) { - repoSelect.style.display = "none"; - repoLabel.style.display = "none"; - } else { - repoSelect.style.display = ""; - repoLabel.style.display = ""; - } - // Focus the prompt input - initialMsg.focus(); - } catch (err) { - repoSelect.textContent = ""; - const opt = document.createElement("option"); - opt.textContent = "Error loading repos"; - repoSelect.appendChild(opt); - } - } -``` - -**Step 3: Verify manually** - -The creation has no automated test suite — test by opening `creation/index.html` in a browser: -- The "NEW SESSION" view should show only REPO (if >1) and PROMPT fields -- With a single repo, only the PROMPT input and EXEC button are visible -- Session name auto-generates server-side from the message - -**Step 4: Commit** - -```bash -git add creation/index.html creation/js/app.js -git commit -m "feat: simplify session creation — remove name field, auto-hide single repo" -``` - ---- - -### Task 5: Output visual hierarchy and density - -**Files:** -- Modify: `creation/css/styles.css:243-297` -- Modify: `creation/js/app.js:199-234` - -**Step 1: Update CSS for visual hierarchy** - -In `creation/css/styles.css`, replace the message styles (`.msg` through `.msg-result::before`) with: - -```css -/* Chat messages — visual hierarchy */ -.msg { - padding: 3px 8px; - font-size: 11px; - line-height: 1.3; -} - -.msg-user { - background: #0a1a0a; - padding: 4px 8px; - margin-top: 6px; - font-size: 11px; - color: #33ff33; - text-shadow: 0 0 4px rgba(51, 255, 51, 0.3); -} - -.msg-user::before { - content: "> "; - font-weight: 700; -} - -.msg-text { - white-space: pre-wrap; - word-break: break-word; - text-shadow: 0 0 4px rgba(51, 255, 51, 0.3); - margin-top: 2px; -} - -.msg-text.truncated { - max-height: 4.4em; /* ~4 lines at 1.1em line-height */ - overflow: hidden; - position: relative; -} - -.msg-text.truncated::after { - content: "..."; - position: absolute; - bottom: 0; - right: 8px; - background: #0a0a0a; - padding-left: 4px; - color: #0a6e0a; -} - -.msg-text.expanded { - max-height: none; -} - -.msg-text.expanded::after { - display: none; -} - -.msg-tool { - color: #0a6e0a; - font-size: 10px; - padding: 2px 8px 2px 10px; - border-left: 2px solid #0a6e0a; - margin-left: 6px; -} - -.msg-tool::before { - content: "[TOOL] "; - color: #33ff33; -} - -.msg-tool-group { - color: #0a6e0a; - font-size: 10px; - padding: 2px 8px 2px 10px; - border-left: 2px solid #0a6e0a; - margin-left: 6px; - cursor: pointer; -} - -.msg-tool-group::before { - content: "[TOOLS] "; - color: #33ff33; -} - -.msg-error { - color: #ff3333; - font-size: 10px; - padding: 2px 8px 2px 10px; - border-left: 2px solid #ff3333; - margin-left: 6px; - text-shadow: 0 0 4px rgba(255, 51, 51, 0.3); -} - -.msg-error::before { - content: "[ERR] "; -} - -.msg-result { - background: #0a2a0a; - color: #33ff33; - font-size: 12px; - padding: 4px 8px; - margin-top: 4px; - font-weight: 700; - text-shadow: 0 0 6px rgba(51, 255, 51, 0.5); -} - -.msg-result::before { - content: "[OK] "; -} -``` - -**Step 2: Update app.js — user messages, tool collapsing, text truncation, auto-scroll** - -In `creation/js/app.js`, update `handleEvent` and `appendMsg` plus add new helpers: - -1. Add a tool batching buffer above the `handleEvent` function: - -```javascript - let toolBuffer = []; - let toolFlushTimer = null; -``` - -2. Add helper functions for tool collapsing and text truncation: - -```javascript - function flushToolBuffer() { - if (toolBuffer.length === 0) return; - if (toolBuffer.length === 1) { - appendRawMsg("msg-tool", toolBuffer[0]); - } else { - const names = toolBuffer.map((t) => t.split(" ")[0]); - const unique = [...new Set(names)]; - const summary = `${toolBuffer.length} tools: ${unique.join(", ")}`; - const group = document.createElement("div"); - group.className = "msg msg-tool-group"; - group.textContent = summary; - // Tap to expand - const details = toolBuffer.slice(); - group.addEventListener("click", () => { - if (group.nextElementSibling?.classList.contains("msg-tool")) return; // already expanded - details.forEach((t) => { - const el = document.createElement("div"); - el.className = "msg msg-tool"; - el.textContent = t; - group.after(el); - }); - }); - chatMessages.appendChild(group); - chatMessages.scrollTop = chatMessages.scrollHeight; - } - toolBuffer = []; - } -``` - -3. Replace `handleEvent` to use the buffer and add user messages: - -```javascript - function handleEvent(event) { - switch (event.type) { - case "text": - flushToolBuffer(); - appendTruncatedMsg("msg-text", event.content); - break; - case "tool": { - const label = event.file - ? event.action + " " + event.file - : event.cmd - ? "$ " + event.cmd - : event.action; - toolBuffer.push(label); - clearTimeout(toolFlushTimer); - toolFlushTimer = setTimeout(flushToolBuffer, 300); - break; - } - case "result": - flushToolBuffer(); - if (event.summary) appendRawMsg("msg-result", event.summary); - updateChatStatus("waiting"); - Notify.fire(); - // Auto-scroll so result is near top of viewport - setTimeout(() => { - const results = chatMessages.querySelectorAll(".msg-result"); - const last = results[results.length - 1]; - if (last) last.scrollIntoView({ block: "start" }); - }, 50); - break; - case "status": - flushToolBuffer(); - updateChatStatus(event.state); - break; - case "error": - flushToolBuffer(); - appendRawMsg("msg-error", event.message || "Unknown error"); - updateChatStatus("error"); - break; - } - } -``` - -4. Rename old `appendMsg` to `appendRawMsg` and add `appendTruncatedMsg` and `appendUserMsg`: - -```javascript - function appendRawMsg(cls, text) { - const div = document.createElement("div"); - div.className = "msg " + cls; - div.textContent = text; - chatMessages.appendChild(div); - chatMessages.scrollTop = chatMessages.scrollHeight; - } - - function appendTruncatedMsg(cls, text) { - const div = document.createElement("div"); - div.className = "msg " + cls + " truncated"; - div.textContent = text; - // Tap to expand - div.addEventListener("click", () => { - div.classList.toggle("truncated"); - div.classList.toggle("expanded"); - }); - chatMessages.appendChild(div); - chatMessages.scrollTop = chatMessages.scrollHeight; - } - - function appendUserMsg(text) { - const div = document.createElement("div"); - div.className = "msg msg-user"; - div.textContent = text; - chatMessages.appendChild(div); - chatMessages.scrollTop = chatMessages.scrollHeight; - } -``` - -5. Update `sendMsg()` to use `appendUserMsg` instead of `appendMsg("msg-text", "> " + text)`: - -```javascript - appendUserMsg(text); -``` - -6. Update ALL other calls from `appendMsg(` to `appendRawMsg(` — search the file for remaining `appendMsg(` calls and rename them. These are in: - - `openPalette` catch block: `appendMsg("msg-error", ...)` → `appendRawMsg("msg-error", ...)` - - `sendMsg` catch block: `appendMsg("msg-error", ...)` → `appendRawMsg("msg-error", ...)` - - `startSession` catch block: `appendMsg("msg-error", ...)` → `appendRawMsg("msg-error", ...)` - - STT spike handlers (longPressStart): all `appendMsg(` → `appendRawMsg(` - -**Step 3: Verify manually** - -Open the creation in a browser. Check that: -- User messages have `>` prefix and dim background -- Tool events get left border accent -- Multiple consecutive tools collapse into a single "[N tools]" line -- Text blocks truncate at ~4 lines with "..." — tap to expand -- Result `[OK]` lines are brighter and larger -- After a result arrives, it scrolls near the top of the view - -**Step 4: Commit** - -```bash -git add creation/css/styles.css creation/js/app.js -git commit -m "feat: output visual hierarchy — user msgs, tool collapsing, text truncation" -``` - ---- - -### Task 6: Command palette from session list - -**Files:** -- Modify: `creation/index.html:17-19` -- Modify: `creation/js/app.js:255-327,369-385,500-514` -- Modify: `creation/js/api.js` - -**Step 1: Add CMD button to sessions footer** - -In `creation/index.html`, update the sessions footer to include both buttons: - -```html - -``` - -**Step 2: Add CSS for the footer row** - -In `creation/css/styles.css`, add after the `.btn-primary:active` rule: - -```css -.session-footer-row { - display: flex; - gap: 4px; -} - -.session-footer-row .btn-primary { - flex: 1; -} - -.btn-cmd-lg { - background: transparent; - border: 1px solid #0a6e0a; - color: #0a6e0a; - font-family: "Courier New", "Lucida Console", monospace; - font-size: 11px; - font-weight: 700; - padding: 6px 8px; - cursor: pointer; - text-transform: uppercase; - letter-spacing: 1px; -} - -.btn-cmd-lg:active { - border-color: #33ff33; - color: #33ff33; -} -``` - -**Step 3: Update app.js — palette from sessions creates new session** - -In `creation/js/app.js`: - -1. Add a flag to track where the palette was opened from: - -```javascript - let paletteFromSessions = false; -``` - -2. Update `openPalette` to accept an optional source parameter — no changes needed to the function body itself, just track the source: - -```javascript - async function openPalette(fromSessions) { - paletteFromSessions = !!fromSessions; - // ... rest of existing openPalette code unchanged ... - } -``` - -3. Update `selectPaletteItem` to handle session creation when opened from sessions list: - -```javascript - function selectPaletteItem(index) { - const item = paletteItems[index]; - if (!item) return; - closePalette(); - - if (paletteFromSessions) { - // Create a new session with this prompt - createSessionFromPalette(item); - return; - } - - if (item.type === "template") { - // Pre-fill input with template, select placeholder for overtype - const match = item.prompt.match(/\{[^}]+\}/); - if (match) { - msgInput.value = item.prompt; - msgInput.focus(); - const start = item.prompt.indexOf(match[0]); - msgInput.setSelectionRange(start, start + match[0].length); - } else { - msgInput.value = item.prompt; - sendMsg(); - } - } else { - // Actions and recents — send immediately - msgInput.value = item.prompt; - sendMsg(); - } - } -``` - -4. Add the `createSessionFromPalette` function: - -```javascript - async function createSessionFromPalette(item) { - // Templates from session list: go to new session view with pre-filled prompt - if (item.type === "template") { - showView("new"); - initialMsg.value = item.prompt; - const match = item.prompt.match(/\{[^}]+\}/); - if (match) { - initialMsg.focus(); - const start = item.prompt.indexOf(match[0]); - initialMsg.setSelectionRange(start, start + match[0].length); - } - return; - } - - // Actions and recents: auto-create session with default/first repo - try { - const repos = await Warren.fetchRepos(); - const repoName = Object.keys(repos)[0]; - if (!repoName) { - appendRawMsg("msg-error", "No repos configured"); - return; - } - const data = await Warren.createSession(repoName, item.prompt); - openSession({ id: data.session_id, name: data.name, repo: data.repo }); - } catch (err) { - appendRawMsg("msg-error", "Failed to create session"); - } - } -``` - -5. Wire up the button handlers. Update the existing `btn-cmd` handler and add the new one: - -```javascript - document.getElementById("btn-cmd").addEventListener("click", () => openPalette(false)); - document.getElementById("btn-cmd-sessions").addEventListener("click", () => openPalette(true)); -``` - -**Step 4: Verify manually** - -Open creation in a browser: -- Session list view shows `[+ NEW]` and `[CMD]` side by side -- Tapping CMD from sessions opens the palette -- Selecting an action auto-creates a session and opens chat -- Selecting a template navigates to new session view with prompt pre-filled -- CMD from chat still works as before (sends to current session) - -**Step 5: Commit** - -```bash -git add creation/index.html creation/css/styles.css creation/js/app.js -git commit -m "feat: command palette accessible from session list for zero-typing actions" -``` - ---- - -### Task 7: Final verification - -**Step 1: Run full test suite and lint** - -```bash -cd server && uv run ruff check src/ tests/ -cd server && uv run pytest tests/ -v -``` - -Expected: all pass, lint clean. - -**Step 2: Verify no regressions in existing functionality** - -Check that: -- Existing session creation with name still works (server accepts `name: null`) -- Sessions list, chat, SSE streaming all work -- Command palette in chat still works -- Admin endpoints still work - -**Step 3: Commit any fixes if needed, then push** - -```bash -git push origin main -``` diff --git a/docs/plans/2026-02-20-settings-dashboard-design.md b/docs/plans/2026-02-20-settings-dashboard-design.md deleted file mode 100644 index e3a5eaa..0000000 --- a/docs/plans/2026-02-20-settings-dashboard-design.md +++ /dev/null @@ -1,82 +0,0 @@ -# Warren Settings Dashboard Design - -## Goal - -Add a browser-accessible settings dashboard so config changes (commands, per-repo model/prompt) don't require SSH + editing config.yaml manually. - -## Architecture - -Admin-gated SPA-style dashboard served at `/settings`. Vanilla HTML/CSS/JS with fetch-based saves to API endpoints. Config persisted to YAML and updated in memory. Matches existing admin cookie auth flow. - -## 1. API Endpoints - -### `GET /config` (admin-gated) - -Returns all editable config sections: - -```json -{ - "commands": { - "actions": [{"label": "RUN TESTS", "prompt": "run the test suite"}], - "templates": [{"label": "FIX BUG", "prompt": "fix the bug in {description}"}] - }, - "repos": { - "warren": {"model": "opus", "append_system_prompt": "This is Warren..."}, - "other-project": {"model": "", "append_system_prompt": ""} - } -} -``` - -### `PUT /config/repos` (admin-gated) - -Updates `model` and `append_system_prompt` per repo. Does NOT add/remove repos or change paths/git_urls. - -```json -{ - "warren": {"model": "opus", "append_system_prompt": "..."}, - "other-project": {"model": "sonnet", "append_system_prompt": ""} -} -``` - -### `PUT /config/commands` (existing) - -Already implemented. Updates actions and templates. - -## 2. Settings Dashboard UI - -Standalone HTML page at `/settings`. Not inside the R1 Creation (240x282 is too small for editing). - -**Auth flow:** Page loads, tries `GET /config`. If 403, redirects to `/admin` login page. After login, cookie is set, redirects back to `/settings`. - -**Layout:** Dark CRT theme (matching Warren aesthetic), full-screen friendly. Two sections: - -**Commands section:** -- Action list: label + prompt fields per row, delete button, "Add action" button -- Template list: label + prompt fields per row, delete button, "Add template" button -- Save button PUTs to `/config/commands` - -**Repos section:** -- Per repo: name (read-only), model dropdown (empty/opus/sonnet/haiku), system prompt textarea -- Save button PUTs to `/config/repos` - -**Tech:** Single HTML file with inline CSS/JS. Vanilla JS, fetch-based. No framework. - -## 3. Config Persistence - -Add `save_repos()` to `config.py` mirroring existing `save_commands()`: -- Reads YAML, updates only `model` and `append_system_prompt` per repo -- Preserves `path`, `git_url`, and all other config fields -- Updates `app.state.config` in memory after save (no restart needed) - -## Files Affected - -**Server:** -- `server/src/warren/config.py` — add `save_repos()` helper -- `server/src/warren/app.py` — add `GET /config`, `PUT /config/repos`, serve settings page, admin redirect - -**Dashboard:** -- `server/settings.html` (new) — standalone settings dashboard - -**Tests:** -- `server/tests/test_config.py` — test `save_repos()` -- `server/tests/test_app.py` — test new endpoints (auth, GET, PUT round-trip) diff --git a/docs/plans/2026-02-20-settings-dashboard-plan.md b/docs/plans/2026-02-20-settings-dashboard-plan.md deleted file mode 100644 index 3831589..0000000 --- a/docs/plans/2026-02-20-settings-dashboard-plan.md +++ /dev/null @@ -1,344 +0,0 @@ -# Settings Dashboard Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add a browser-accessible admin settings dashboard for editing commands and per-repo settings without SSH. - -**Architecture:** Admin-gated SPA-style dashboard at `/settings`. Vanilla HTML/CSS/JS with fetch-based saves. New `save_repos()` helper mirrors existing `save_commands()`. Two new API endpoints: `GET /config` and `PUT /config/repos`. Admin cookie auth (same flow as `/admin` login). - -**Tech Stack:** FastAPI, pydantic, PyYAML, vanilla HTML/CSS/JS - ---- - -### Task 1: `save_repos()` helper - -**Files:** -- Modify: `server/src/warren/config.py:59-66` (add `save_repos` next to `save_commands`) -- Test: `server/tests/test_config.py` - -**Step 1: Write the failing test** - -Add to `server/tests/test_config.py`: - -```python -def test_save_repos_updates_model_and_prompt(tmp_path): - """save_repos() updates repo model/prompt in YAML and preserves other fields.""" - from warren.config import save_repos - - config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump({ - "repos": { - "my-project": { - "path": "/home/user/project", - "git_url": "git@github.com:user/project.git", - "model": "", - "append_system_prompt": "", - }, - "other": { - "path": "/tmp/other", - }, - }, - "max_concurrent_sessions": 4, - "commands": {"actions": [{"label": "TEST", "prompt": "run tests"}], "templates": []}, - })) - - save_repos(str(config_file), { - "my-project": {"model": "opus", "append_system_prompt": "This is a web app."}, - "other": {"model": "haiku", "append_system_prompt": ""}, - }) - - with open(config_file) as f: - data = yaml.safe_load(f) - - # Repo settings updated - assert data["repos"]["my-project"]["model"] == "opus" - assert data["repos"]["my-project"]["append_system_prompt"] == "This is a web app." - assert data["repos"]["other"]["model"] == "haiku" - # path, git_url preserved - assert data["repos"]["my-project"]["path"] == "/home/user/project" - assert data["repos"]["my-project"]["git_url"] == "git@github.com:user/project.git" - assert data["repos"]["other"]["path"] == "/tmp/other" - # Other config fields preserved - assert data["max_concurrent_sessions"] == 4 - assert data["commands"]["actions"][0]["label"] == "TEST" -``` - -**Step 2: Run test to verify it fails** - -Run: `cd server && uv run pytest tests/test_config.py::test_save_repos_updates_model_and_prompt -v` -Expected: FAIL with `ImportError: cannot import name 'save_repos'` - -**Step 3: Write minimal implementation** - -Add to `server/src/warren/config.py` after `save_commands()`: - -```python -def save_repos(path: str, repos: dict[str, dict[str, str]]) -> None: - """Update model and append_system_prompt for repos in the YAML config file.""" - p = Path(path) - with open(p) as f: - data = yaml.safe_load(f) or {} - existing = data.get("repos", {}) - for name, updates in repos.items(): - if name in existing: - existing[name]["model"] = updates.get("model", "") - existing[name]["append_system_prompt"] = updates.get("append_system_prompt", "") - data["repos"] = existing - with open(p, "w") as f: - yaml.dump(data, f, default_flow_style=False) -``` - -**Step 4: Run test to verify it passes** - -Run: `cd server && uv run pytest tests/test_config.py -v` -Expected: All PASS - -**Step 5: Commit** - -```bash -git add server/src/warren/config.py server/tests/test_config.py -git commit -m "feat: add save_repos() helper for persisting repo settings" -``` - ---- - -### Task 2: `GET /config` and `PUT /config/repos` endpoints - -**Files:** -- Modify: `server/src/warren/app.py:26` (add `save_repos` import) -- Modify: `server/src/warren/app.py:230-242` (add endpoints after existing `PUT /config/commands`) -- Test: `server/tests/test_app.py` - -**Step 1: Write the failing tests** - -Add to `server/tests/test_app.py`: - -```python -class TestConfigEndpoints: - async def test_get_config_requires_admin(self, client): - resp = await client.get("/config") - assert resp.status_code == 403 - - async def test_get_config_returns_commands_and_repos(self, client): - resp = await client.get("/config", headers=ADMIN_HEADERS) - assert resp.status_code == 200 - data = resp.json() - # Commands section - assert "commands" in data - assert len(data["commands"]["actions"]) == 1 - assert data["commands"]["actions"][0]["label"] == "RUN TESTS" - # Repos section (model + prompt only, no path/git_url) - assert "repos" in data - assert "test-repo" in data["repos"] - assert data["repos"]["test-repo"]["model"] == "opus" - assert "path" not in data["repos"]["test-repo"] - - async def test_put_repos_requires_admin(self, client): - resp = await client.put("/config/repos", json={ - "test-repo": {"model": "haiku", "append_system_prompt": ""}, - }) - assert resp.status_code == 403 - - async def test_put_repos_updates_and_returns(self, client): - resp = await client.put( - "/config/repos", - headers=ADMIN_HEADERS, - json={ - "test-repo": {"model": "haiku", "append_system_prompt": "Updated prompt."}, - }, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["test-repo"]["model"] == "haiku" - assert data["test-repo"]["append_system_prompt"] == "Updated prompt." - - async def test_put_repos_round_trip(self, client): - """PUT then GET reflects updated repo settings.""" - await client.put( - "/config/repos", - headers=ADMIN_HEADERS, - json={ - "test-repo": {"model": "sonnet", "append_system_prompt": "New prompt."}, - }, - ) - resp = await client.get("/config", headers=ADMIN_HEADERS) - assert resp.status_code == 200 - data = resp.json() - assert data["repos"]["test-repo"]["model"] == "sonnet" - assert data["repos"]["test-repo"]["append_system_prompt"] == "New prompt." -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_app.py::TestConfigEndpoints -v` -Expected: FAIL (404 — endpoints don't exist yet) - -**Step 3: Write minimal implementation** - -In `server/src/warren/app.py`, update the import line: - -```python -from warren.config import CommandsConfig, Settings, load_config, save_commands, save_repos -``` - -Add these endpoints after the existing `PUT /config/commands` block (after line 242): - -```python - @app.get("/config", dependencies=[Depends(require_admin)]) - async def get_config(request: Request): - config = request.app.state.config - return { - "commands": { - "actions": [ - {"label": a.label, "prompt": a.prompt} - for a in config.commands.actions - ], - "templates": [ - {"label": t.label, "prompt": t.prompt} - for t in config.commands.templates - ], - }, - "repos": { - name: { - "model": repo.model, - "append_system_prompt": repo.append_system_prompt, - } - for name, repo in config.repos.items() - }, - } - - @app.put("/config/repos", dependencies=[Depends(require_admin)]) - async def update_repos(request: Request): - body = await request.json() - config_path = request.app.state.settings.config - save_repos(config_path, body) - # Update in-memory config - for name, updates in body.items(): - if name in request.app.state.config.repos: - request.app.state.config.repos[name].model = updates.get("model", "") - request.app.state.config.repos[name].append_system_prompt = updates.get("append_system_prompt", "") - return { - name: { - "model": repo.model, - "append_system_prompt": repo.append_system_prompt, - } - for name, repo in request.app.state.config.repos.items() - if name in body - } -``` - -**Step 4: Run tests to verify they pass** - -Run: `cd server && uv run pytest tests/test_app.py -v` -Expected: All PASS - -**Step 5: Lint** - -Run: `cd server && uv run ruff check src/ tests/` -Expected: Clean - -**Step 6: Commit** - -```bash -git add server/src/warren/app.py server/tests/test_app.py -git commit -m "feat: add GET /config and PUT /config/repos admin endpoints" -``` - ---- - -### Task 3: Settings dashboard HTML page - -**Files:** -- Create: `server/settings.html` -- Modify: `server/src/warren/app.py` (serve the settings page) - -**Step 1: Create the settings page** - -Create `server/settings.html`. This is a standalone SPA-style page with inline CSS/JS: - -- Dark CRT theme matching Warren's aesthetic (green on black, monospace) -- On load: fetch `GET /config`, populate forms -- Commands section: editable list of actions (label + prompt), editable list of templates (label + prompt), add/delete buttons, save button → `PUT /config/commands` -- Repos section: per-repo model dropdown (empty/opus/sonnet/haiku) + system prompt textarea, save button → `PUT /config/repos` -- Auth: all fetches include credentials (cookie). If any fetch returns 403, redirect to `/admin` -- Success/error feedback inline (no alerts) - -The full HTML content is provided at implementation time since it's a complete file. Key structure: - -```html - - - - Warren Settings - - - -

WARREN:// SETTINGS

-
-
- - - -``` - -**Step 2: Serve the settings page from app.py** - -Add a route in `server/src/warren/app.py` that serves the HTML file with admin auth. Add after the `admin_login` endpoint: - -```python - @app.get("/settings", dependencies=[Depends(require_admin)]) - async def settings_page(): - settings_candidates = [ - Path("/app/server/settings.html"), - Path(__file__).parent.parent.parent / "settings.html", - ] - for candidate in settings_candidates: - if candidate.is_file(): - return HTMLResponse(content=candidate.read_text()) - raise HTTPException(status_code=404, detail="Settings page not found") -``` - -**Step 3: Update admin login redirect** - -Currently `/admin/login` redirects to `/pair/qr`. We should keep that as default, but the settings page should handle its own auth check by trying `GET /config` and redirecting to `/admin` on 403. No change to the login endpoint needed — the settings JS handles the redirect. - -**Step 4: Run all tests** - -Run: `cd server && uv run pytest tests/ -v` -Expected: All PASS - -**Step 5: Lint** - -Run: `cd server && uv run ruff check src/ tests/` -Expected: Clean - -**Step 6: Commit** - -```bash -git add server/settings.html server/src/warren/app.py -git commit -m "feat: add settings dashboard page" -``` - ---- - -### Task 4: Final verification - -**Step 1: Run full test suite** - -Run: `cd server && uv run pytest tests/ -v` -Expected: All PASS - -**Step 2: Lint** - -Run: `cd server && uv run ruff check src/ tests/` -Expected: Clean - -**Step 3: Manual smoke test description** - -1. Start server: `cd server && uv run python -m warren` -2. Open browser to `http://localhost:4030/settings` — should redirect to `/admin` -3. Enter admin token, should redirect to `/pair/qr` (existing behavior) -4. Navigate to `http://localhost:4030/settings` — should load dashboard with current config -5. Edit a command label, save — should show success -6. Edit a repo model, save — should show success -7. Reload page — changes should persist diff --git a/docs/plans/2026-02-20-warren-gateway-design.md b/docs/plans/2026-02-20-warren-gateway-design.md deleted file mode 100644 index ef811e4..0000000 --- a/docs/plans/2026-02-20-warren-gateway-design.md +++ /dev/null @@ -1,317 +0,0 @@ -# Warren Universal AI Gateway Design - -## Summary - -Warren becomes a universal AI gateway. Instead of building container -orchestration, scheduling, and swarm infrastructure natively, Warren -uses a backend adapter interface. The R1 talks to Warren. Warren talks -to whatever backend handles AI execution. - -NanoClaw becomes the first managed backend via file-based IPC. Warren's -current bare Claude CLI mode becomes the "direct" backend adapter. -AI-native patterns (skills, self-setup, self-healing) are built in -Warren's `.claude/skills/` to be extracted to a reusable library later. - -**Supersedes:** `2026-02-20-nanoclaw-integration-design.md` (native build -approach). That design assumed Warren would port NanoClaw's internals to -Python. This design keeps NanoClaw intact and makes Warren a thin adapter. - ---- - -## Architecture - -``` -R1 Device - | - | HTTPS / SSE - v -+----------------------------------------------+ -| Warren (FastAPI) | -| | -| R1 Interface: Backend Adapters: | -| - QR pairing - DirectAdapter | -| - Session routing (bare claude CLI) | -| - SSE streaming - NanoClawAdapter | -| - Command palette (file-based IPC) | -| - Creation serving - [Future adapters] | -| | -| AI-Native: | -| - .claude/skills/* | -| - Self-setup (/setup) | -| - Self-healing (/debug) | -| - Self-extending (/customize) | -+----------------------------------------------+ - | | - | subprocess | file IPC - v v - claude CLI NanoClaw (Node.js) - (local dev) | - | docker run - v - +----------+ +----------+ - | Agent A | | Agent B | - +----------+ +----------+ -``` - -**Warren owns:** -- R1 UI, device management, auth, pairing -- Session routing and protocol translation -- Backend adapter interface and adapter implementations -- AI-native skills for self-setup, customization, debugging -- Config (which backend, backend-specific settings) - -**Warren does NOT own:** -- Container orchestration (NanoClaw) -- Cron scheduling (NanoClaw) -- Swarm orchestration (NanoClaw) -- Notifications (NanoClaw) -- AI execution (backend) - ---- - -## Part 1: Backend Adapter Interface - -Message-based, not RPC. Warren sends messages into the adapter and -receives events back. The adapter is a bidirectional event pipe. - -### Protocol - -```python -class BackendAdapter(Protocol): - async def send(self, message: BackendMessage) -> None: - """Send a message to the backend.""" - ... - - def events(self) -> AsyncIterator[BackendEvent]: - """Stream of events from the backend.""" - ... - - async def start(self) -> None: - """Initialize the adapter.""" - ... - - async def stop(self) -> None: - """Graceful shutdown.""" - ... -``` - -### Message and Event Types - -```python -@dataclass -class BackendMessage: - kind: str - session_id: str | None = None - payload: dict = field(default_factory=dict) - -@dataclass -class BackendEvent: - kind: str - session_id: str - data: dict = field(default_factory=dict) -``` - -Message kinds: `new_session`, `user_message`, `cancel` - -Event kinds: `text`, `tool_use`, `result`, `status`, `error` - -Adapters translate between Warren's vocabulary and whatever the backend -speaks. Warren's app.py never knows which backend it's talking to. - ---- - -## Part 2: DirectAdapter - -Refactors the current `ClaudeRunner` into the adapter interface. This is -what Warren does today, wrapped in the new abstraction. - -- `new_session` message -> spawn `claude` subprocess with `--print`, - `--output-format stream-json` -- Subprocess stdout -> parsed into `BackendEvent`s via existing - `parse_stream_line()` -- `user_message` message -> spawn subprocess with `--resume` -- `cancel` message -> terminate subprocess - -No behavioral change from the user's perspective. Existing R1 sessions -work exactly as before. This adapter is the default for local -development and simple deployments. - ---- - -## Part 3: NanoClawAdapter - -Communicates with NanoClaw via its file-based IPC protocol. Both -services run on the same VPS. - -### IPC Flow - -1. Warren writes a task request file to the shared IPC directory -2. NanoClaw's IPC watcher picks it up and spawns a container -3. Container runs Claude CLI, writes output with sentinel markers -4. NanoClaw writes result files to IPC directory -5. Warren's adapter polls/watches for result files -6. Adapter translates results to `BackendEvent`s for SSE streaming - -### Session Mapping - -The adapter maintains a mapping between Warren session IDs and -NanoClaw task/container IDs. Stored in Warren's SQLite for -persistence across restarts. - -### NanoClaw IPC Protocol - -Exact file format and polling mechanics TBD during implementation -- -requires detailed reading of NanoClaw's `src/ipc.ts`. The high-level -pattern is file-based message passing with structured JSON payloads. - ---- - -## Part 4: AI-Native Skills - -Built directly in Warren's `.claude/skills/`. Follows NanoClaw's -philosophy: "Fix it. Don't tell the user to go fix it themselves." - -### Skills - -``` -.claude/skills/ - setup/ - SKILL.md # VPS deployment wizard - scripts/ - 01-check-env.sh # Detect OS, Python, Docker, Node - 02-install-deps.sh # uv sync, NanoClaw setup - 03-configure.sh # config.yaml, .env, Dokploy volumes - 04-verify.sh # End-to-end health check - customize/ - SKILL.md # Open-ended: "add X to Warren" - debug/ - SKILL.md # Diagnose adapter, IPC, containers -``` - -### Structured Status Blocks - -Health check scripts emit JSON: -```json -{"component": "warren", "status": "ok", "version": "0.2.0"} -{"component": "nanoclaw", "status": "ok", "version": "1.0.0"} -{"component": "ipc", "status": "error", "detail": "shared volume not mounted"} -{"component": "docker", "status": "ok", "version": "24.0.7"} -``` - -The `/debug` skill reads these and fixes problems automatically. - -### Self-Documenting CLAUDE.md - -Warren's CLAUDE.md expands with extension points: - -``` -## Extension Points - -To add a backend adapter: create server/src/warren/adapters/{name}.py -implementing BackendAdapter protocol. Register in config.py. - -To add an API endpoint: add route in app.py inside create_app(). - -To add a skill: create .claude/skills/{name}/SKILL.md. -``` - -### Future: Extractable Patterns - -These patterns (structured status blocks, skill anatomy, self-healing -loops, fix-it philosophy) are designed to be extracted into a standalone -patterns library later. Warren is the proving ground. - ---- - -## Part 5: Deployment - -Two Dokploy services on the same VPS: - -### Warren Service -- FastAPI container (current setup, mostly unchanged) -- Shared volume: `/opt/warren-data/ipc/` for NanoClaw IPC -- Environment: `WARREN_BACKEND=nanoclaw` (or `direct` for standalone) - -### NanoClaw Service -- Node.js container -- Docker socket mounted: `/var/run/docker.sock` -- Shared volume: `/opt/warren-data/ipc/` for Warren IPC -- NanoClaw's own config for repos, container profiles, scheduled tasks - -### Setup Flow (guided by /setup skill) - -1. Deploy Warren via Dokploy (existing workflow) -2. Deploy NanoClaw via Dokploy (new service) -3. Configure shared volume in both services -4. Mount Docker socket in NanoClaw service -5. Set `WARREN_BACKEND=nanoclaw` in Warren -6. Configure NanoClaw repos, tasks, and container profiles -7. Verify end-to-end: R1 -> Warren -> NanoClaw -> container -> response - ---- - -## Part 6: Config Changes - -### Settings (env vars) - -```python -class Settings(BaseSettings): - # ... existing ... - backend: str = "direct" # "direct" or "nanoclaw" - data_dir: str = "data" # shared data directory -``` - -### Config (YAML) - -```yaml -backend_config: - direct: - claude_bin: claude - max_concurrent: 4 - nanoclaw: - ipc_dir: /opt/warren-data/ipc -``` - ---- - -## Part 7: API Surface - -R1-facing API is mostly unchanged. The backend adapter is invisible -to clients. - -Existing endpoints work identically: -- `POST /sessions` -> creates session via whichever backend -- `POST /sessions/{id}/msg` -> sends message via adapter -- `GET /sessions/{id}/stream` -> SSE from adapter events -- All auth, pairing, config endpoints unchanged - -Optional future additions (proxy to NanoClaw): -- `GET /api/tasks` -> scheduled task status -- `GET /api/monitor/health` -> system health -- These would be thin proxies reading NanoClaw's state - ---- - -## New/Modified Files - -``` -server/src/warren/ - adapters/ - __init__.py # BackendAdapter protocol, message/event types - direct.py # DirectAdapter (refactored ClaudeRunner) - nanoclaw.py # NanoClawAdapter (file-based IPC) - config.py # + backend, data_dir settings - -.claude/skills/ - setup/SKILL.md + scripts/ - customize/SKILL.md - debug/SKILL.md - -CLAUDE.md # + extension points -``` - -## Dependencies - -- No new Python dependencies for the adapter interface -- NanoClaw installed separately (Node.js) -- Docker on VPS (for NanoClaw's container runtime) diff --git a/docs/plans/2026-02-20-warren-gateway-plan.md b/docs/plans/2026-02-20-warren-gateway-plan.md deleted file mode 100644 index f1a1c10..0000000 --- a/docs/plans/2026-02-20-warren-gateway-plan.md +++ /dev/null @@ -1,1388 +0,0 @@ -# Warren Universal AI Gateway Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Refactor Warren from a tightly-coupled Claude CLI wrapper into a universal AI gateway with pluggable backend adapters, then add AI-native skills for self-setup, customization, and debugging. - -**Architecture:** Message-based BackendAdapter protocol. DirectAdapter wraps existing ClaudeRunner. NanoClawAdapter (stub) prepares for file-based IPC integration. Global event pump in app.py replaces per-session background tasks. AI-native skills in `.claude/skills/`. - -**Tech Stack:** Python 3.11, FastAPI, aiosqlite, pydantic-settings, asyncio - -**Design Doc:** `docs/plans/2026-02-20-warren-gateway-design.md` - ---- - -## Phase 1: Backend Adapter Interface - -### Task 1: Define adapter types (protocol, messages, events) - -**Files:** -- Create: `server/src/warren/adapters/__init__.py` -- Create: `server/tests/test_adapters.py` - -**Step 1: Write failing tests** - -Create `server/tests/test_adapters.py`: - -```python -"""Tests for backend adapter types.""" - - -def test_backend_message_defaults(): - from warren.adapters import BackendMessage - - msg = BackendMessage(kind="new_session") - assert msg.kind == "new_session" - assert msg.session_id is None - assert msg.payload == {} - - -def test_backend_event_creation(): - from warren.adapters import BackendEvent - - event = BackendEvent(kind="text", session_id="abc", data={"content": "hello"}) - assert event.kind == "text" - assert event.session_id == "abc" - assert event.data["content"] == "hello" - - -def test_backend_message_with_payload(): - from warren.adapters import BackendMessage - - msg = BackendMessage( - kind="new_session", - session_id="abc", - payload={"message": "hello", "repo": "test"}, - ) - assert msg.session_id == "abc" - assert msg.payload["message"] == "hello" -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_adapters.py -v` -Expected: FAIL — `ModuleNotFoundError: No module named 'warren.adapters'` - -**Step 3: Write minimal implementation** - -Create `server/src/warren/adapters/__init__.py`: - -```python -"""Backend adapter interface for Warren. - -Warren uses a message-based adapter protocol to communicate with AI backends. -Send BackendMessages in, receive BackendEvents out. The adapter translates -between Warren's vocabulary and whatever the backend speaks. -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from dataclasses import dataclass, field -from typing import Protocol, runtime_checkable - - -@dataclass -class BackendMessage: - """Message sent from Warren to a backend adapter.""" - - kind: str # "new_session", "user_message", "cancel" - session_id: str | None = None - payload: dict = field(default_factory=dict) - - -@dataclass -class BackendEvent: - """Event emitted by a backend adapter back to Warren.""" - - kind: str # "text", "tool", "result", "status", "error" - session_id: str - data: dict = field(default_factory=dict) - - -@runtime_checkable -class BackendAdapter(Protocol): - """Protocol that all backend adapters must satisfy.""" - - async def send(self, message: BackendMessage) -> None: ... - def events(self) -> AsyncIterator[BackendEvent]: ... - async def start(self) -> None: ... - async def stop(self) -> None: ... -``` - -**Step 4: Run tests to verify they pass** - -Run: `cd server && uv run pytest tests/test_adapters.py -v` -Expected: 3 passed - -**Step 5: Commit** - -```bash -git add server/src/warren/adapters/__init__.py server/tests/test_adapters.py -git commit -m "feat: add backend adapter protocol and message types" -``` - ---- - -### Task 2: Implement DirectAdapter - -**Files:** -- Create: `server/src/warren/adapters/direct.py` -- Modify: `server/tests/test_adapters.py` - -**Step 1: Write failing tests** - -Append to `server/tests/test_adapters.py`: - -```python -import asyncio -from unittest.mock import patch - -import pytest - - -class TestDirectAdapter: - async def test_new_session_produces_events(self): - from warren.adapters import BackendMessage - from warren.adapters.direct import DirectAdapter - - adapter = DirectAdapter(claude_bin="claude") - - events_to_yield = [ - {"type": "status", "state": "working"}, - {"type": "text", "content": "Hello"}, - {"type": "result", "summary": "Done", "session_id": "claude-123"}, - {"type": "status", "state": "waiting"}, - ] - - async def mock_run(**kwargs): - for e in events_to_yield: - yield e - - with patch.object(adapter._runner, "run", side_effect=mock_run): - await adapter.send(BackendMessage( - kind="new_session", - session_id="test-session", - payload={"message": "hello", "repo_path": "/tmp/test"}, - )) - - collected = [] - async for event in adapter.events(): - collected.append(event) - if event.kind == "status" and event.data.get("state") == "waiting": - break - - assert len(collected) == 4 - assert all(e.session_id == "test-session" for e in collected) - assert collected[0].kind == "status" - assert collected[1].kind == "text" - assert collected[2].kind == "result" - assert collected[3].kind == "status" - - async def test_user_message_passes_resume_id(self): - from warren.adapters import BackendMessage - from warren.adapters.direct import DirectAdapter - - adapter = DirectAdapter(claude_bin="claude") - captured = {} - - async def mock_run(**kwargs): - captured.update(kwargs) - yield {"type": "status", "state": "working"} - yield {"type": "status", "state": "waiting"} - - with patch.object(adapter._runner, "run", side_effect=mock_run): - await adapter.send(BackendMessage( - kind="user_message", - session_id="test-session", - payload={ - "message": "continue", - "repo_path": "/tmp/test", - "resume_session_id": "claude-abc", - }, - )) - - # Drain events - async for event in adapter.events(): - if event.kind == "status" and event.data.get("state") == "waiting": - break - - assert captured["resume_session_id"] == "claude-abc" - - async def test_cancel_removes_task(self): - from warren.adapters import BackendMessage - from warren.adapters.direct import DirectAdapter - - adapter = DirectAdapter(claude_bin="claude") - - # Manually create a tracked task - async def long_running(): - await asyncio.sleep(100) - - adapter._tasks["test-session"] = asyncio.create_task(long_running()) - - await adapter.send(BackendMessage(kind="cancel", session_id="test-session")) - assert "test-session" not in adapter._tasks - - async def test_error_produces_error_event(self): - from warren.adapters import BackendMessage - from warren.adapters.direct import DirectAdapter - - adapter = DirectAdapter(claude_bin="claude") - - async def mock_run(**kwargs): - raise FileNotFoundError("/tmp/missing") - yield # make it a generator # noqa: RUF027 - - with patch.object(adapter._runner, "run", side_effect=mock_run): - await adapter.send(BackendMessage( - kind="new_session", - session_id="test-session", - payload={"message": "hello", "repo_path": "/tmp/missing"}, - )) - - collected = [] - async for event in adapter.events(): - collected.append(event) - if event.kind == "status" and event.data.get("state") == "waiting": - break - - assert any(e.kind == "error" for e in collected) - assert collected[-1].kind == "status" - assert collected[-1].data["state"] == "waiting" - - async def test_stop_cancels_all_tasks(self): - from warren.adapters.direct import DirectAdapter - - adapter = DirectAdapter(claude_bin="claude") - - async def long_running(): - await asyncio.sleep(100) - - adapter._tasks["a"] = asyncio.create_task(long_running()) - adapter._tasks["b"] = asyncio.create_task(long_running()) - - await adapter.stop() - assert len(adapter._tasks) == 0 -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_adapters.py::TestDirectAdapter -v` -Expected: FAIL — `ModuleNotFoundError: No module named 'warren.adapters.direct'` - -**Step 3: Write implementation** - -Create `server/src/warren/adapters/direct.py`: - -```python -"""Direct backend adapter -- runs Claude CLI via subprocess. - -Wraps the existing ClaudeRunner, adapting its async-generator interface -to the message/event BackendAdapter protocol. -""" - -from __future__ import annotations - -import asyncio -import logging -from collections.abc import AsyncIterator - -from warren.adapters import BackendEvent, BackendMessage -from warren.claude_runner import ClaudeRunner - -logger = logging.getLogger(__name__) - - -class DirectAdapter: - """Backend adapter that runs Claude CLI directly via subprocess.""" - - def __init__(self, claude_bin: str = "claude", max_concurrent: int = 4): - self._runner = ClaudeRunner(claude_bin=claude_bin, max_concurrent=max_concurrent) - self._event_queue: asyncio.Queue[BackendEvent] = asyncio.Queue() - self._tasks: dict[str, asyncio.Task] = {} - - async def start(self) -> None: - """No-op for direct adapter (no external connections).""" - - async def stop(self) -> None: - """Cancel all running sessions.""" - for task in self._tasks.values(): - task.cancel() - self._tasks.clear() - - async def send(self, message: BackendMessage) -> None: - """Handle an incoming message from Warren.""" - if message.kind in ("new_session", "user_message"): - self._spawn_run(message) - elif message.kind == "cancel": - task = self._tasks.pop(message.session_id, None) - if task: - task.cancel() - - async def events(self) -> AsyncIterator[BackendEvent]: - """Yield events from all running sessions.""" - while True: - event = await self._event_queue.get() - yield event - - def _spawn_run(self, message: BackendMessage) -> None: - """Start a Claude CLI subprocess for a session.""" - session_id = message.session_id - payload = message.payload - - async def _run() -> None: - try: - async for event in self._runner.run( - message=payload["message"], - repo_path=payload["repo_path"], - resume_session_id=payload.get("resume_session_id"), - model=payload.get("model", ""), - append_system_prompt=payload.get("append_system_prompt", ""), - ): - await self._event_queue.put( - BackendEvent( - kind=event["type"], - session_id=session_id, - data=event, - ) - ) - except FileNotFoundError as exc: - repo_path = payload.get("repo_path", "unknown") - await self._event_queue.put(BackendEvent( - kind="error", - session_id=session_id, - data={"type": "error", "message": f"Repo not found: {exc.filename or repo_path}"}, - )) - await self._event_queue.put(BackendEvent( - kind="status", - session_id=session_id, - data={"type": "status", "state": "waiting"}, - )) - except Exception as exc: - logger.exception("Direct adapter: session %s failed", session_id) - await self._event_queue.put(BackendEvent( - kind="error", - session_id=session_id, - data={"type": "error", "message": str(exc)}, - )) - await self._event_queue.put(BackendEvent( - kind="status", - session_id=session_id, - data={"type": "status", "state": "waiting"}, - )) - finally: - self._tasks.pop(session_id, None) - - self._tasks[session_id] = asyncio.create_task(_run()) -``` - -**Step 4: Run tests to verify they pass** - -Run: `cd server && uv run pytest tests/test_adapters.py -v` -Expected: 8 passed (3 type tests + 5 DirectAdapter tests) - -**Step 5: Run all existing tests to verify no regressions** - -Run: `cd server && uv run pytest tests/ -v` -Expected: All existing tests still pass (no changes to existing code) - -**Step 6: Commit** - -```bash -git add server/src/warren/adapters/direct.py server/tests/test_adapters.py -git commit -m "feat: add DirectAdapter wrapping ClaudeRunner" -``` - ---- - -### Task 3: Add backend selection to config - -**Files:** -- Modify: `server/src/warren/config.py` -- Modify: `server/tests/test_config.py` - -**Step 1: Write failing tests** - -Append to `server/tests/test_config.py`: - -```python -def test_settings_backend_defaults(): - """Settings defaults to 'direct' backend.""" - from warren.config import Settings - - s = Settings(anthropic_api_key="sk-test") - assert s.backend == "direct" - assert s.data_dir == "data" - - -def test_config_with_backend_config(tmp_path): - """backend_config section parsed from YAML.""" - from warren.config import load_config - - config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump({ - "repos": {"test": {"path": "/tmp"}}, - "backend_config": { - "direct": {"claude_bin": "claude", "max_concurrent": 2}, - "nanoclaw": {"ipc_dir": "/opt/warren-data/ipc"}, - }, - })) - - config = load_config(str(config_file)) - assert config.backend_config["direct"]["claude_bin"] == "claude" - assert config.backend_config["nanoclaw"]["ipc_dir"] == "/opt/warren-data/ipc" - - -def test_config_without_backend_config(tmp_path): - """backend_config is optional -- defaults to empty dict.""" - from warren.config import load_config - - config_file = tmp_path / "config.yaml" - config_file.write_text(yaml.dump({ - "repos": {"test": {"path": "/tmp"}}, - })) - - config = load_config(str(config_file)) - assert config.backend_config == {} -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_config.py::test_settings_backend_defaults tests/test_config.py::test_config_with_backend_config tests/test_config.py::test_config_without_backend_config -v` -Expected: FAIL — `AttributeError: 'Settings' object has no attribute 'backend'` - -**Step 3: Add fields to config models** - -In `server/src/warren/config.py`, add to `WarrenConfig`: - -```python -backend_config: dict[str, dict] = {} -``` - -Add to `Settings`: - -```python -backend: str = "direct" -data_dir: str = "data" -``` - -**Step 4: Run tests to verify they pass** - -Run: `cd server && uv run pytest tests/test_config.py -v` -Expected: All config tests pass - -**Step 5: Run full test suite** - -Run: `cd server && uv run pytest tests/ -v` -Expected: All tests pass - -**Step 6: Commit** - -```bash -git add server/src/warren/config.py server/tests/test_config.py -git commit -m "feat: add backend selection to config" -``` - ---- - -### Task 4: Wire adapter into app.py - -This is the critical refactoring task. Replace `ClaudeRunner` usage in `app.py` -with the adapter interface. Add a global event pump. - -**Files:** -- Modify: `server/src/warren/app.py` -- Modify: `server/tests/test_app.py` - -**Step 1: Add adapter factory function to app.py** - -Add after the existing imports in `server/src/warren/app.py`: - -```python -from warren.adapters import BackendAdapter, BackendEvent, BackendMessage -from warren.adapters.direct import DirectAdapter -``` - -Add a factory function (before `create_app`): - -```python -def create_adapter(settings: Settings, config) -> DirectAdapter: - """Create the backend adapter based on settings.""" - if settings.backend == "direct": - backend_cfg = config.backend_config.get("direct", {}) - return DirectAdapter( - claude_bin=backend_cfg.get("claude_bin", settings.claude_bin), - max_concurrent=backend_cfg.get("max_concurrent", config.max_concurrent_sessions), - ) - raise ValueError(f"Unknown backend: {settings.backend}") -``` - -**Step 2: Add event pump function** - -Add in `server/src/warren/app.py` (before `create_app`): - -```python -async def event_pump( - adapter: BackendAdapter, - event_queues: dict[str, asyncio.Queue], - db: Database, -) -> None: - """Route backend events to per-session SSE queues.""" - async for event in adapter.events(): - queue = event_queues.get(event.session_id) - if queue: - await queue.put(event.data) - if ( - event.kind == "result" - and event.data.get("session_id") - ): - await db.update_claude_session_id( - event.session_id, event.data["session_id"] - ) -``` - -**Step 3: Update lifespan** - -Replace the `ClaudeRunner` setup in the `lifespan` context manager: - -Before (lines 73-77): -```python - app.state.runner = ClaudeRunner( - claude_bin=settings.claude_bin, - max_concurrent=config.max_concurrent_sessions, - ) -``` - -After: -```python - adapter = create_adapter(settings, config) - await adapter.start() - app.state.adapter = adapter -``` - -Also replace (line 79): -```python - # session_id -> asyncio.Queue for SSE events - app.state.event_queues: dict[str, asyncio.Queue] = {} -``` - -With: -```python - app.state.event_queues: dict[str, asyncio.Queue] = {} - - pump_task = asyncio.create_task(event_pump( - adapter, app.state.event_queues, db, - )) -``` - -Update teardown (around line 91). Before: -```python - lifecycle_task.cancel() -``` - -After: -```python - pump_task.cancel() - lifecycle_task.cancel() - await adapter.stop() -``` - -**Step 4: Update create_session endpoint** - -Replace the `_run` background task in `create_session` (lines 330-374). - -Before: -```python - runner: ClaudeRunner = request.app.state.runner - queues: dict[str, asyncio.Queue] = request.app.state.event_queues - - session_id = uuid.uuid4().hex - name = body.name or f"{body.repo} session" - await db.create_session(session_id, name, body.repo) - await db.save_message(session_id, body.message) - - # Create event queue and run Claude in background (non-blocking) - queue: asyncio.Queue = asyncio.Queue() - queues[session_id] = queue - - async def _run(): - try: - async for event in runner.run( - message=body.message, - repo_path=repo_path, - model=repo_config.model, - append_system_prompt=repo_config.append_system_prompt, - ): - await queue.put(event) - if ( - event.get("type") == "result" - and event.get("session_id") - ): - await db.update_claude_session_id( - session_id, event["session_id"] - ) - except FileNotFoundError as e: - await queue.put({ - "type": "error", - "message": f"Repo not found: {e.filename or repo_path}", - }) - await queue.put({"type": "status", "state": "waiting"}) - except Exception as e: - logger.exception("Claude runner failed") - await queue.put({ - "type": "error", - "message": str(e), - }) - await queue.put({"type": "status", "state": "waiting"}) - - asyncio.create_task(_run()) - return {"session_id": session_id, "name": name, "repo": body.repo} -``` - -After: -```python - adapter = request.app.state.adapter - queues: dict[str, asyncio.Queue] = request.app.state.event_queues - - session_id = uuid.uuid4().hex - name = body.name or f"{body.repo} session" - await db.create_session(session_id, name, body.repo) - await db.save_message(session_id, body.message) - - queues[session_id] = asyncio.Queue() - - await adapter.send(BackendMessage( - kind="new_session", - session_id=session_id, - payload={ - "message": body.message, - "repo_path": repo_path, - "model": repo_config.model, - "append_system_prompt": repo_config.append_system_prompt, - }, - )) - - return {"session_id": session_id, "name": name, "repo": body.repo} -``` - -**Step 5: Update send_message endpoint** - -Replace the `_run` background task in `send_message` (lines 376-429). - -Before: -```python - runner: ClaudeRunner = request.app.state.runner - queues: dict[str, asyncio.Queue] = request.app.state.event_queues - ... - claude_sid = session.get("claude_session_id") - - async def _run(): - try: - async for event in runner.run( - message=body.message, - repo_path=repo_path, - resume_session_id=claude_sid, - model=repo_config.model, - append_system_prompt=repo_config.append_system_prompt, - ): - await queue.put(event) - ... - except Exception as e: - ... - - asyncio.create_task(_run()) - return {"status": "streaming", "session_id": session_id} -``` - -After: -```python - adapter = request.app.state.adapter - queues: dict[str, asyncio.Queue] = request.app.state.event_queues - - if session_id not in queues: - queues[session_id] = asyncio.Queue() - - await db.touch(session_id) - await db.save_message(session_id, body.message) - await db.update_status(session_id, "active") - - claude_sid = session.get("claude_session_id") - - await adapter.send(BackendMessage( - kind="user_message", - session_id=session_id, - payload={ - "message": body.message, - "repo_path": repo_path, - "resume_session_id": claude_sid, - "model": repo_config.model, - "append_system_prompt": repo_config.append_system_prompt, - }, - )) - - return {"status": "streaming", "session_id": session_id} -``` - -**Step 6: Update delete_session endpoint** - -Add cancel message. After `queues.pop(session_id, None)`: - -```python - adapter = request.app.state.adapter - await adapter.send(BackendMessage(kind="cancel", session_id=session_id)) -``` - -**Step 7: Remove ClaudeRunner import** - -Remove from app.py imports: -```python -from warren.claude_runner import ClaudeRunner -``` - -**Step 8: Update test fixture** - -In `server/tests/test_app.py`, update the `client` fixture. - -Replace (line 50): -```python - app.state.runner = ClaudeRunner(claude_bin="claude", max_concurrent=2) -``` - -With: -```python - from warren.adapters.direct import DirectAdapter - adapter = DirectAdapter(claude_bin="claude", max_concurrent=2) - await adapter.start() - app.state.adapter = adapter -``` - -Remove the `ClaudeRunner` import from the fixture (line 14): -```python - from warren.claude_runner import ClaudeRunner -``` - -**Step 9: Update TestRepoConfig test** - -Replace `test_create_session_passes_model_to_runner` (lines 383-401): - -Before: -```python - async def test_create_session_passes_model_to_runner(self, authed_client): - captured = {} - - async def mock_run(*args, **kwargs): - captured.update(kwargs) - yield {"type": "status", "state": "working"} - yield {"type": "result", "session_id": "mock-123", "summary": "Done"} - yield {"type": "status", "state": "waiting"} - - with patch("warren.app.ClaudeRunner.run", side_effect=mock_run): - resp = await authed_client.post("/sessions", json={ - "repo": "test-repo", - "message": "hello", - }) - assert resp.status_code == 200 - - assert captured.get("model") == "opus" - assert "test system prompt" in captured.get("append_system_prompt", "") -``` - -After: -```python - async def test_create_session_passes_config_to_adapter(self, authed_client): - captured_messages = [] - original_send = authed_client.app.state.adapter.send - - async def mock_send(message): - captured_messages.append(message) - - with patch.object(authed_client.app.state.adapter, "send", side_effect=mock_send): - resp = await authed_client.post("/sessions", json={ - "repo": "test-repo", - "message": "hello", - }) - assert resp.status_code == 200 - - assert len(captured_messages) == 1 - msg = captured_messages[0] - assert msg.payload["model"] == "opus" - assert "test system prompt" in msg.payload.get("append_system_prompt", "") -``` - -**Step 10: Run full test suite** - -Run: `cd server && uv run pytest tests/ -v` -Expected: All tests pass - -**Step 11: Lint** - -Run: `cd server && uv run ruff check src/ tests/` -Expected: No errors (fix any import ordering issues) - -**Step 12: Commit** - -```bash -git add server/src/warren/app.py server/tests/test_app.py -git commit -m "refactor: wire BackendAdapter into app.py, replace direct ClaudeRunner usage" -``` - ---- - -### Task 5: Add NanoClaw adapter stub - -**Files:** -- Create: `server/src/warren/adapters/nanoclaw.py` -- Modify: `server/tests/test_adapters.py` -- Modify: `server/src/warren/app.py` (adapter factory) - -**Step 1: Write test** - -Append to `server/tests/test_adapters.py`: - -```python -class TestNanoClawAdapter: - async def test_instantiation(self): - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir="/tmp/ipc") - assert adapter._ipc_dir == "/tmp/ipc" - - async def test_send_raises_not_implemented(self): - from warren.adapters import BackendMessage - from warren.adapters.nanoclaw import NanoClawAdapter - - adapter = NanoClawAdapter(ipc_dir="/tmp/ipc") - with pytest.raises(NotImplementedError): - await adapter.send(BackendMessage(kind="new_session", session_id="x")) -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_adapters.py::TestNanoClawAdapter -v` -Expected: FAIL — `ModuleNotFoundError` - -**Step 3: Write stub implementation** - -Create `server/src/warren/adapters/nanoclaw.py`: - -```python -"""NanoClaw backend adapter -- file-based IPC with NanoClaw. - -Stub implementation. Full IPC integration requires studying NanoClaw's -src/ipc.ts protocol and implementing file-based message passing. - -See: https://github.com/qwibitai/nanoclaw -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator - -from warren.adapters import BackendEvent, BackendMessage - - -class NanoClawAdapter: - """Backend adapter that communicates with NanoClaw via file-based IPC.""" - - def __init__(self, ipc_dir: str): - self._ipc_dir = ipc_dir - - async def start(self) -> None: - raise NotImplementedError("NanoClaw adapter not yet implemented") - - async def stop(self) -> None: - raise NotImplementedError("NanoClaw adapter not yet implemented") - - async def send(self, message: BackendMessage) -> None: - raise NotImplementedError("NanoClaw adapter not yet implemented") - - async def events(self) -> AsyncIterator[BackendEvent]: - raise NotImplementedError("NanoClaw adapter not yet implemented") - yield # pragma: no cover -``` - -**Step 4: Update adapter factory in app.py** - -In `server/src/warren/app.py`, update `create_adapter`: - -```python -def create_adapter(settings: Settings, config): - """Create the backend adapter based on settings.""" - if settings.backend == "direct": - backend_cfg = config.backend_config.get("direct", {}) - return DirectAdapter( - claude_bin=backend_cfg.get("claude_bin", settings.claude_bin), - max_concurrent=backend_cfg.get("max_concurrent", config.max_concurrent_sessions), - ) - if settings.backend == "nanoclaw": - from warren.adapters.nanoclaw import NanoClawAdapter - backend_cfg = config.backend_config.get("nanoclaw", {}) - return NanoClawAdapter(ipc_dir=backend_cfg.get("ipc_dir", "data/ipc")) - raise ValueError(f"Unknown backend: {settings.backend}") -``` - -**Step 5: Run tests** - -Run: `cd server && uv run pytest tests/test_adapters.py -v` -Expected: All adapter tests pass - -**Step 6: Commit** - -```bash -git add server/src/warren/adapters/nanoclaw.py server/tests/test_adapters.py server/src/warren/app.py -git commit -m "feat: add NanoClaw adapter stub" -``` - ---- - -## Phase 2: AI-Native Foundation (Skills) - -Skills have no code dependencies. They are documentation + scripts that guide -Claude Code through complex operations on Warren itself. - -### Task 6: Create /setup skill - -**Files:** -- Create: `.claude/skills/setup/SKILL.md` -- Create: `.claude/skills/setup/scripts/01-check-env.sh` -- Create: `.claude/skills/setup/scripts/02-install-deps.sh` -- Create: `.claude/skills/setup/scripts/03-configure.sh` -- Create: `.claude/skills/setup/scripts/04-verify.sh` - -**Step 1: Write SKILL.md** - -Create `.claude/skills/setup/SKILL.md`: - -```markdown ---- -name: setup -description: Deploy Warren on a VPS with AI-guided setup. Checks environment, installs dependencies, configures services, and verifies end-to-end. ---- - -# Warren Setup - -Deploy Warren to a Linux VPS. This skill guides you through the full process, -fixing problems as they arise. - -**Principle:** Fix it. Don't tell the user to go fix it themselves — unless it -genuinely requires their manual action (API keys, domain config). - -## Prerequisites - -Before starting, ask the user for: -- Target VPS hostname or confirm we're running on the target machine -- Whether they use Dokploy, systemd, or docker-compose for deployment -- Their Anthropic API key (if not already set) - -## Step 1: Check Environment - -Run `bash .claude/skills/setup/scripts/01-check-env.sh` and parse the JSON -output. For each component: - -- **ok**: Move on -- **missing**: Install it (see Step 2) -- **outdated**: Upgrade it - -Required components: Python 3.11+, uv, Node.js 22+, Docker, git - -## Step 2: Install Dependencies - -Run `bash .claude/skills/setup/scripts/02-install-deps.sh` for any missing -components. If a package manager isn't available, install the appropriate one -first. - -Then install Warren's Python dependencies: -``` -cd server && uv sync -``` - -## Step 3: Configure - -Run `bash .claude/skills/setup/scripts/03-configure.sh` to check for existing -configuration. Then: - -1. **config.yaml** — Create or update with repo paths. Ask the user which - repos they want Warren to manage. -2. **.env** — Set `WARREN_ANTHROPIC_API_KEY`, `WARREN_ADMIN_TOKEN` (generate - if not set), `WARREN_BACKEND` (direct or nanoclaw). -3. **Deployment** — Based on their deployment method: - - Dokploy: guide service creation, volume mounts, env vars - - systemd: create service unit file - - docker-compose: create compose.yaml - -## Step 4: Verify - -Run `bash .claude/skills/setup/scripts/04-verify.sh` and parse the JSON -output. For each component: - -- **ok**: Report success -- **error**: Diagnose and fix, then re-run verification - -Required checks: Warren starts, health endpoint responds, config loads, at -least one repo is accessible. - -## Troubleshooting - -If any step fails: -1. Read the error output carefully -2. Check common causes (permissions, ports in use, missing env vars) -3. Fix the issue -4. Re-run the failing step -5. If stuck after 2 attempts, ask the user for help -``` - -**Step 2: Write check scripts** - -Create `.claude/skills/setup/scripts/01-check-env.sh`: - -```bash -#!/usr/bin/env bash -set -euo pipefail - -check() { - local name="$1" cmd="$2" min_version="${3:-}" - if command -v "$cmd" &>/dev/null; then - version=$("$cmd" --version 2>&1 | head -1 || echo "unknown") - echo "{\"component\":\"$name\",\"status\":\"ok\",\"version\":\"$version\"}" - else - echo "{\"component\":\"$name\",\"status\":\"missing\"}" - fi -} - -check "python" "python3" "3.11" -check "uv" "uv" -check "node" "node" "22" -check "docker" "docker" -check "git" "git" -``` - -Create `.claude/skills/setup/scripts/02-install-deps.sh`: - -```bash -#!/usr/bin/env bash -set -euo pipefail - -# Detect OS -if [ -f /etc/os-release ]; then - . /etc/os-release - echo "{\"component\":\"os\",\"status\":\"ok\",\"distro\":\"$ID\",\"version\":\"$VERSION_ID\"}" -else - echo "{\"component\":\"os\",\"status\":\"error\",\"detail\":\"Cannot detect OS\"}" - exit 1 -fi - -# Install uv if missing -if ! command -v uv &>/dev/null; then - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "{\"component\":\"uv\",\"status\":\"installed\"}" -fi - -# Install project deps -cd "$(dirname "$0")/../../.." || exit 1 -if [ -d server ]; then - cd server && uv sync - echo "{\"component\":\"warren_deps\",\"status\":\"ok\"}" -fi -``` - -Create `.claude/skills/setup/scripts/03-configure.sh`: - -```bash -#!/usr/bin/env bash -set -euo pipefail - -PROJECT_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" - -# Check existing config -if [ -f "$PROJECT_ROOT/server/config.yaml" ]; then - echo "{\"component\":\"config\",\"status\":\"ok\",\"path\":\"$PROJECT_ROOT/server/config.yaml\"}" -else - echo "{\"component\":\"config\",\"status\":\"missing\",\"path\":\"$PROJECT_ROOT/server/config.yaml\"}" -fi - -# Check .env -if [ -f "$PROJECT_ROOT/server/.env" ]; then - echo "{\"component\":\"env\",\"status\":\"ok\"}" -else - echo "{\"component\":\"env\",\"status\":\"missing\"}" -fi -``` - -Create `.claude/skills/setup/scripts/04-verify.sh`: - -```bash -#!/usr/bin/env bash -set -euo pipefail - -# Check if Warren is running by hitting health endpoint -WARREN_PORT="${WARREN_PORT:-4030}" - -if curl -sf "http://localhost:$WARREN_PORT/health" >/dev/null 2>&1; then - echo "{\"component\":\"warren\",\"status\":\"ok\"}" -else - echo "{\"component\":\"warren\",\"status\":\"error\",\"detail\":\"Health endpoint not responding on port $WARREN_PORT\"}" -fi -``` - -**Step 3: Make scripts executable and commit** - -```bash -chmod +x .claude/skills/setup/scripts/*.sh -git add .claude/skills/setup/ -git commit -m "feat: add /setup skill for AI-guided deployment" -``` - ---- - -### Task 7: Create /customize skill - -**Files:** -- Create: `.claude/skills/customize/SKILL.md` - -**Step 1: Write SKILL.md** - -Create `.claude/skills/customize/SKILL.md`: - -```markdown ---- -name: customize -description: Modify Warren to fit your needs. Add endpoints, change behavior, configure backends, extend the UI. ---- - -# Customize Warren - -This skill handles any open-ended customization request. Understand the -request, plan changes, implement, test. - -## Warren Architecture - -Before making changes, understand the codebase: - -- `server/src/warren/app.py` — FastAPI app factory, all endpoints, SSE streaming -- `server/src/warren/adapters/` — Backend adapter interface and implementations - - `__init__.py` — BackendAdapter protocol, BackendMessage, BackendEvent types - - `direct.py` — DirectAdapter (bare Claude CLI via subprocess) - - `nanoclaw.py` — NanoClawAdapter stub (file-based IPC) -- `server/src/warren/claude_runner.py` — Claude CLI subprocess management -- `server/src/warren/config.py` — pydantic-settings (env) + YAML config -- `server/src/warren/db.py` — SQLite via aiosqlite -- `server/src/warren/lifecycle.py` — Background session lifecycle sweep -- `server/src/warren/auth.py` — QR pairing, device tokens -- `creation/` — R1 WebView UI (vanilla HTML/CSS/JS, 240x282 viewport) - -## Process - -1. **Understand** — Read the relevant source files. Check CLAUDE.md for - conventions and extension points. -2. **Plan** — Describe what you'll change and why. List files to create/modify. -3. **Test first** — Write a failing test for the new behavior. -4. **Implement** — Write minimal code to make the test pass. -5. **Verify** — Run `cd server && uv run pytest tests/ -v` and - `cd server && uv run ruff check src/ tests/`. -6. **Commit** — Small, focused commits. - -## Common Customizations - -### Add an API endpoint -1. Add route in `app.py` inside `create_app()` -2. Add test in `tests/test_app.py` -3. If it needs auth, use `Depends(require_auth)` or `Depends(require_admin)` - -### Add a backend adapter -1. Create `server/src/warren/adapters/{name}.py` implementing `BackendAdapter` -2. Add tests in `tests/test_adapters.py` -3. Register in `create_adapter()` in `app.py` -4. Add config section to `backend_config` in config.yaml - -### Modify R1 UI -1. Edit files in `creation/` (vanilla HTML/CSS/JS) -2. Viewport is 240x282 pixels — design for this exact size -3. Test in browser at that resolution - -### Change config -1. Add pydantic model fields in `config.py` -2. Add tests in `tests/test_config.py` -3. Update CLAUDE.md if it's an extension point -``` - -**Step 2: Commit** - -```bash -git add .claude/skills/customize/ -git commit -m "feat: add /customize skill for AI-driven extensibility" -``` - ---- - -### Task 8: Create /debug skill - -**Files:** -- Create: `.claude/skills/debug/SKILL.md` - -**Step 1: Write SKILL.md** - -Create `.claude/skills/debug/SKILL.md`: - -```markdown ---- -name: debug -description: Diagnose and fix Warren issues. Adapter failures, session problems, networking, configuration. ---- - -# Debug Warren - -Systematic troubleshooting for Warren. Run diagnostics, parse output, fix -issues automatically. - -**Principle:** Fix it. Don't tell the user to go fix it themselves. - -## Diagnostic Steps - -### 1. Check service health -```bash -curl -sf http://localhost:${WARREN_PORT:-4030}/health -``` -If this fails, Warren isn't running. Check: -- Process running: `pgrep -f "warren"` or `docker ps` -- Port in use: `ss -tlnp | grep ${WARREN_PORT:-4030}` -- Logs: `journalctl -u warren --since "5 minutes ago"` or docker logs - -### 2. Check configuration -```bash -cd server && uv run python -c " -from warren.config import Settings, load_config -s = Settings() -print(f'Backend: {s.backend}') -print(f'DB: {s.db}') -print(f'Config: {s.config}') -c = load_config(s.config) -print(f'Repos: {list(c.repos.keys())}') -print(f'Backend config: {c.backend_config}') -" -``` - -### 3. Check adapter connectivity -For direct backend: -- Verify `claude` binary exists: `which claude` -- Test Claude CLI: `claude --version` -- Check API key: verify `ANTHROPIC_API_KEY` is set - -For NanoClaw backend: -- Check IPC directory exists and is writable -- Check NanoClaw process is running -- Check Docker socket is accessible - -### 4. Check database -```bash -cd server && uv run python -c " -import asyncio, aiosqlite -async def check(): - db = await aiosqlite.connect('warren.db') - tables = await (await db.execute( - \"SELECT name FROM sqlite_master WHERE type='table'\" - )).fetchall() - print(f'Tables: {[t[0] for t in tables]}') - sessions = await (await db.execute( - 'SELECT count(*), status FROM sessions GROUP BY status' - )).fetchall() - print(f'Sessions: {dict(sessions)}') - await db.close() -asyncio.run(check()) -" -``` - -### 5. Check session streaming -If SSE isn't working: -- Verify event queue exists for the session -- Check adapter is producing events -- Test with curl: `curl -N -H "Authorization: Bearer TOKEN" http://localhost:4030/sessions/SID/stream` - -## Common Issues - -| Symptom | Likely Cause | Fix | -|---------|-------------|-----| -| 401 on all endpoints | Invalid device token | Re-pair via /admin | -| Session stays "working" | Claude subprocess hung | Check adapter tasks, kill stale processes | -| No SSE events | Event pump not running | Check logs for pump errors, restart | -| Config not loading | YAML syntax error | Validate with `python -c "import yaml; yaml.safe_load(open('config.yaml'))"` | -| Port already in use | Another instance running | `kill $(lsof -t -i:4030)` or change port | -``` - -**Step 2: Commit** - -```bash -git add .claude/skills/debug/ -git commit -m "feat: add /debug skill for AI-guided troubleshooting" -``` - ---- - -### Task 9: Expand CLAUDE.md with extension points - -**Files:** -- Modify: `CLAUDE.md` - -**Step 1: Add extension points section** - -Append to `CLAUDE.md`: - -```markdown - -## Extension Points - -### Add a backend adapter -Create `server/src/warren/adapters/{name}.py` implementing the `BackendAdapter` -protocol from `warren.adapters`. Register it in `create_adapter()` in `app.py`. -Add backend-specific config under `backend_config` in config.yaml. - -### Add an API endpoint -Add a route inside `create_app()` in `app.py`. Use `Depends(require_auth)` for -device-token auth or `Depends(require_admin)` for admin-only access. - -### Add a skill -Create `.claude/skills/{name}/SKILL.md`. The skill is automatically available -to Claude Code when working in the Warren repo. - -### Key abstractions -- `BackendAdapter` (`server/src/warren/adapters/__init__.py`) — protocol for AI backends -- `BackendMessage` / `BackendEvent` — message types for adapter communication -- `DirectAdapter` (`server/src/warren/adapters/direct.py`) — bare Claude CLI adapter -- `Database` (`server/src/warren/db.py`) — SQLite via aiosqlite -- `WarrenConfig` / `Settings` (`server/src/warren/config.py`) — YAML + env config -``` - -**Step 2: Commit** - -```bash -git add CLAUDE.md -git commit -m "docs: add extension points to CLAUDE.md" -``` - ---- - -## Dependency Graph - -``` -Task 1 (Adapter types) - | -Task 2 (DirectAdapter) - | -Task 3 (Config) - | -Task 4 (Wire into app.py) ---- Tasks 6-9 can run in parallel - | (no code dependencies) -Task 5 (NanoClaw stub) - -Tasks 6-9 (Skills + CLAUDE.md) -- independent of Phase 1 -``` - -## Notes - -- **Phase 2 (skills) has no code dependencies** — can start immediately in - parallel with Phase 1. -- `WARREN_BACKEND=direct` is default — existing deploys unaffected. -- `ClaudeRunner` and `parse_stream_line` stay unchanged — DirectAdapter wraps them. -- All existing tests must pass after every task. -- The NanoClaw adapter is a stub. Full IPC implementation is a separate - future plan once NanoClaw is deployed alongside Warren. diff --git a/docs/plans/2026-02-21-agent-progress-stream-design.md b/docs/plans/2026-02-21-agent-progress-stream-design.md deleted file mode 100644 index 4c01e1d..0000000 --- a/docs/plans/2026-02-21-agent-progress-stream-design.md +++ /dev/null @@ -1,113 +0,0 @@ -# Agent Progress Stream & Auto-Memory Design - -## Goal - -Give the user real-time visibility into what the agent is doing (tool activity stream) and let the agent build persistent knowledge over time (auto-memory). - -## Problem - -1. **Silent work**: When the agent reads files, runs commands, or searches code, the user sees nothing until the final response arrives. On long tasks this feels like the agent is hung. -2. **No memory**: Each session starts from scratch. The agent doesn't learn user preferences or project patterns over time. - -## Design - -### Tool Activity Stream - -**Approach:** Extend the existing result pipeline to also forward tool_use events. Every layer already exists — we widen what flows through it. - -**Data flow:** - -``` -SDK query() yields assistant message with tool_use content blocks - → agent-runner extracts tool name + input, formats compact summary - → writeOutput({ type: "progress", tool, summary }) via stdout markers - → container-runner parses markers in real-time, calls onOutput() - → NanoClaw onOutput dispatches on type: progress → channel.sendProgress() - → WarrenChannel POSTs { session_id, type: "tool", tool, summary } - → Warren callback puts { type: "tool", tool, summary } on SSE queue - → R1 UI handleEvent case "tool": renders compact one-liner -``` - -**Agent-runner changes:** - -The message loop adds handling for `assistant` messages containing `tool_use` blocks: - -```typescript -if (message.type === 'assistant') { - for (const block of message.content) { - if (block.type === 'tool_use') { - const summary = summarizeTool(block.name, block.input); - writeOutput({ type: 'progress', tool: block.name, summary }); - } - } -} -``` - -Tool summary extraction per tool: -- `Read` → file path -- `Bash` → first ~40 chars of command -- `Write`/`Edit` → file path -- `Grep`/`Glob` → pattern -- `WebSearch`/`WebFetch` → query/URL -- Other → just the tool name - -**Output protocol change:** - -Currently `ContainerOutput` is `{ status, result, newSessionId }`. We introduce a discriminated union: - -```typescript -type ContainerEvent = - | { type: 'result'; status: 'success' | 'error'; result: string | null; newSessionId?: string } - | { type: 'progress'; tool: string; summary: string }; -``` - -The container-runner dispatches based on `type`. Progress events call a new callback; result events use the existing path. - -**NanoClaw changes:** - -The `onOutput` handler dispatches on event type: -- `type: 'progress'` → `channel.sendProgress(jid, tool, summary)` -- `type: 'result'` → existing `channel.sendMessage(jid, text)` path - -`WarrenChannel` gets `sendProgress()` that POSTs to the same callback URL: -```json -{ "session_id": "abc", "type": "tool", "tool": "Read", "summary": "server/app.py" } -``` - -**Warren changes:** - -The callback handler adds a case for `type: "tool"`: -```python -elif event_type == "tool": - await queue.put({"type": "tool", "tool": body.get("tool"), "summary": body.get("summary")}) -``` - -**R1 UI changes:** - -Replace the tool-buffering approach with immediate one-liners: - -``` -Reading server/app.py -Running npm test -Searching for "handleEvent" -``` - -Styled as `msg-tool` — dimmer than assistant text, monospace, single line with `text-overflow: ellipsis` for the 240px R1 screen. No debounce, no batching. They scroll by like a build log. - -### Auto-Memory - -The agent container already has the prerequisites: -- `CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0'` in settings.json -- Persistent `/home/node/.claude` mounted from the data volume -- `settingSources: ['project', 'user']` in SDK query options - -The SDK should write to `~/.claude/projects//memory/MEMORY.md` and it persists across sessions via the volume mount. This is a verification task — confirm it works, fix path issues if not. No architectural changes needed. - -## What This Does NOT Include - -- Streaming text chunks (partial responses as they're written) -- Token usage or context size display -- Progress percentages or time estimates -- Claude's internal thinking/reasoning steps - -These could be added later using the same pipeline, but are out of scope for this design. diff --git a/docs/plans/2026-02-21-agent-progress-stream-plan.md b/docs/plans/2026-02-21-agent-progress-stream-plan.md deleted file mode 100644 index 6e5e9ec..0000000 --- a/docs/plans/2026-02-21-agent-progress-stream-plan.md +++ /dev/null @@ -1,450 +0,0 @@ -# Agent Progress Stream Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Stream tool activity from agent containers to the R1 UI as compact one-liners so the user sees real-time progress instead of silence. - -**Architecture:** Extend the existing stdout-markers → container-runner → callback → SSE pipeline. The agent-runner emits progress events for each tool_use block. NanoClaw forwards them via the Warren callback. Warren puts them on the SSE queue. The R1 UI renders them as compact one-liners. - -**Tech Stack:** TypeScript (agent-runner, NanoClaw), Python/FastAPI (Warren), vanilla JS (R1 UI) - ---- - -### Task 1: Add progress output to agent-runner - -The agent-runner message loop currently only emits `result` messages via `writeOutput()`. We add progress events for `tool_use` content blocks. - -**Files:** -- Modify: `nanoclaw/container/agent-runner/src/index.ts:33-38` (ContainerOutput type) -- Modify: `nanoclaw/container/agent-runner/src/index.ts:111-115` (writeOutput function) -- Modify: `nanoclaw/container/agent-runner/src/index.ts:457-486` (message loop) - -**Step 1: Extend the output types** - -Change the `ContainerOutput` interface and add a `ProgressEvent` type. Update `writeOutput` to accept both: - -```typescript -// Replace the existing ContainerOutput (line 33-38) with: -interface ContainerResult { - type: 'result'; - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} - -interface ContainerProgress { - type: 'progress'; - tool: string; - summary: string; -} - -type ContainerOutput = ContainerResult | ContainerProgress; -``` - -Update `writeOutput` — it already works since it just `JSON.stringify`s, but update the existing result writes to include `type: 'result'`: - -```typescript -// In the result handler (line 480-484), change to: -writeOutput({ - type: 'result', - status: 'success', - result: textResult || null, - newSessionId -}); -``` - -Also update the error output at the bottom of `main()` (search for `writeOutput` with `status: 'error'`): - -```typescript -writeOutput({ - type: 'result', - status: 'error', - result: null, - error: `...` -}); -``` - -**Step 2: Add tool summary helper** - -Add a `summarizeTool` function before the message loop: - -```typescript -function summarizeTool(toolName: string, input: Record): string { - switch (toolName) { - case 'Read': - return String(input.file_path || '').split('/').slice(-2).join('/'); - case 'Write': - case 'Edit': - return String(input.file_path || '').split('/').slice(-2).join('/'); - case 'Bash': - return ('$ ' + String(input.command || '')).slice(0, 60); - case 'Grep': - return String(input.pattern || ''); - case 'Glob': - return String(input.pattern || ''); - case 'WebSearch': - return String(input.query || ''); - case 'WebFetch': - return String(input.url || '').slice(0, 60); - default: - return toolName; - } -} -``` - -**Step 3: Emit progress events in the message loop** - -In the `for await` loop (after line 464, inside the `message.type === 'assistant'` block), add tool_use extraction: - -```typescript -if (message.type === 'assistant' && 'uuid' in message) { - lastAssistantUuid = (message as { uuid: string }).uuid; - // Emit progress events for tool_use blocks - const content = (message as { content?: Array<{ type: string; name?: string; input?: Record }> }).content; - if (content) { - for (const block of content) { - if (block.type === 'tool_use' && block.name) { - writeOutput({ - type: 'progress', - tool: block.name, - summary: summarizeTool(block.name, block.input || {}), - }); - } - } - } -} -``` - -**Step 4: Build and verify** - -Run: `cd nanoclaw && npm run build` -Expected: Clean compile, no errors. - -**Step 5: Commit** - -```bash -cd nanoclaw -git add container/agent-runner/src/index.ts -git commit -m "feat: emit tool progress events from agent-runner" -``` - ---- - -### Task 2: Handle progress events in container-runner and NanoClaw - -The container-runner parses stdout markers and calls `onOutput`. Currently it types the parsed JSON as `ContainerOutput` (the old shape). We need to handle the new discriminated union. The NanoClaw `onOutput` handler in `index.ts` needs to dispatch progress events to the channel. - -**Files:** -- Modify: `nanoclaw/src/container-runner.ts:83-99` (ContainerOutput type) -- Modify: `nanoclaw/src/container-runner.ts:373-383` (streaming output parsing) -- Modify: `nanoclaw/src/index.ts:189-207` (onOutput handler) -- Modify: `nanoclaw/src/channels/warren.ts:41-51` (add sendProgress) -- Modify: `nanoclaw/src/types.ts:81-90` (Channel interface) - -**Step 1: Update ContainerOutput type in container-runner.ts** - -Replace the existing `ContainerOutput` interface (around line 93): - -```typescript -export interface ContainerResult { - type: 'result'; - status: 'success' | 'error'; - result: string | null; - newSessionId?: string; - error?: string; -} - -export interface ContainerProgress { - type: 'progress'; - tool: string; - summary: string; -} - -export type ContainerOutput = ContainerResult | ContainerProgress; -``` - -Update the `runContainerAgent` return type and final output parsing to handle both types. In the streaming parser (line 374), `parsed` is already generic JSON — it will have `type: 'progress'` or `type: 'result'`. The `newSessionId` extraction (line 375-377) should only run for result events: - -```typescript -const parsed: ContainerOutput = JSON.parse(jsonStr); -if (parsed.type === 'result' && parsed.newSessionId) { - newSessionId = parsed.newSessionId; -} -``` - -Note: if `type` is missing (backwards compat with old agent images), treat as result. - -**Step 2: Add sendProgress to Channel interface and WarrenChannel** - -In `nanoclaw/src/types.ts`, add to the Channel interface: - -```typescript -export interface Channel { - // ... existing methods ... - sendProgress?(jid: string, tool: string, summary: string): Promise; -} -``` - -In `nanoclaw/src/channels/warren.ts`, add the method: - -```typescript -async sendProgress(jid: string, tool: string, summary: string): Promise { - const sessionId = jid.replace('warren:', ''); - const resp = await fetch(this.callbackUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: sessionId, type: 'tool', tool, summary }), - }); - if (!resp.ok) { - throw new Error(`Warren callback failed: ${resp.status}`); - } -} -``` - -**Step 3: Dispatch progress events in NanoClaw's onOutput handler** - -In `nanoclaw/src/index.ts`, update the `onOutput` callback (line 189-207): - -```typescript -const output = await runAgent(group, prompt, chatJid, async (result) => { - if (result.type === 'progress') { - // Forward tool activity to the user in real-time - await channel.sendProgress?.(chatJid, result.tool, result.summary); - return; - } - // Existing result handling (unchanged) - if (result.result) { - const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); - const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); - logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`); - if (text) { - await channel.sendMessage(chatJid, text); - outputSentToUser = true; - } - resetIdleTimer(); - } - - if (result.status === 'error') { - hadError = true; - } -}); -``` - -Note: We need to handle `result.status` and `result.result` only for result-type events. Since the old `ContainerOutput` didn't have `type`, access `result.status` only when `result.type !== 'progress'` (or `result.type === 'result'` or `type` is undefined for backwards compat). - -**Step 4: Build and verify** - -Run: `cd nanoclaw && npm run build` -Expected: Clean compile. - -**Step 5: Commit** - -```bash -cd nanoclaw -git add src/container-runner.ts src/index.ts src/channels/warren.ts src/types.ts -git commit -m "feat: forward tool progress events through NanoClaw pipeline" -``` - ---- - -### Task 3: Handle tool events in Warren's callback endpoint - -Warren's callback handler needs to recognize `type: "tool"` and put it on the SSE queue. - -**Files:** -- Modify: `warren/server/src/warren/app.py:620-638` (nanoclaw_callback) - -**Step 1: Add tool event handling** - -In the `nanoclaw_callback` function, add a case for tool events: - -```python -@app.post("/internal/nanoclaw/callback") -async def nanoclaw_callback(request: Request): - body = await request.json() - session_id = body.get("session_id") - text = body.get("text", "") - event_type = body.get("type", "text") - - queues: dict[str, asyncio.Queue] = request.app.state.event_queues - queue = queues.get(session_id) - if queue: - if event_type == "result": - await queue.put({ - "type": "result", - "summary": text, - "session_id": body.get("nanoclaw_session_id", ""), - }) - elif event_type == "tool": - await queue.put({ - "type": "tool", - "tool": body.get("tool", ""), - "summary": body.get("summary", ""), - }) - else: - await queue.put({"type": "text", "content": text}) - return {"status": "ok"} -``` - -**Step 2: Verify lint passes** - -Run: `cd server && uv run ruff check src/` -Expected: No errors. - -**Step 3: Run tests** - -Run: `cd server && uv run pytest tests/ -v` -Expected: All existing tests pass. - -**Step 4: Commit** - -```bash -cd warren -git add server/src/warren/app.py -git commit -m "feat: handle tool progress events in nanoclaw callback" -``` - ---- - -### Task 4: Update R1 UI tool rendering - -The R1 UI already has a `case "tool"` handler, but it buffers tools and shows summaries like "3 tools: Read, Bash, Grep". We change it to render immediate compact one-liners using the `summary` field from the new event format. - -**Files:** -- Modify: `warren/creation/js/app.js:264-279` (handleEvent tool case) - -**Step 1: Update the tool event handler** - -Replace the existing `case "tool"` block in `handleEvent`: - -```javascript -case "tool": { - // Show compact one-liner for each tool use - const summary = event.summary - ? event.tool + " " + event.summary - : event.tool || event.action || "working"; - appendRawMsg("msg-tool", summary); - break; -} -``` - -This removes the debounce buffer — each tool event renders immediately. The existing `msg-tool` CSS already handles the styling (green, 10px, `[TOOL]` prefix). - -Also remove the now-unused `toolBuffer`, `toolFlushTimer`, and `flushToolBuffer` references. Search for all calls to `flushToolBuffer()` in other cases (`text`, `result`, `status`, `error`) and remove them since there's no longer a buffer to flush. - -Actually, keep `flushToolBuffer` as a no-op for safety — the DirectAdapter path (non-nanoclaw) might still use the old format: - -```javascript -let toolBuffer = []; -let toolFlushTimer; -function flushToolBuffer() { - if (toolBuffer.length === 0) return; - if (toolBuffer.length === 1) { - appendRawMsg("msg-tool", toolBuffer[0]); - } else { - // ... existing group rendering ... - } - toolBuffer = []; -} -``` - -Keep `flushToolBuffer` calls in other cases. The tool case itself just renders directly when `event.summary` exists (NanoClaw path), or falls back to the old buffer behavior otherwise: - -```javascript -case "tool": { - if (event.summary !== undefined) { - // NanoClaw path: immediate one-liner - flushToolBuffer(); - const label = event.tool + " " + event.summary; - appendRawMsg("msg-tool", label); - } else { - // Direct adapter path: buffer and batch - const label = event.file - ? event.action + " " + event.file - : event.cmd - ? "$ " + event.cmd - : event.action; - toolBuffer.push(label); - clearTimeout(toolFlushTimer); - toolFlushTimer = setTimeout(flushToolBuffer, 300); - } - break; -} -``` - -**Step 2: Commit** - -```bash -cd warren -git add creation/js/app.js -git commit -m "feat: render tool progress as immediate one-liners in R1 UI" -``` - ---- - -### Task 5: Verify auto-memory - -Verify that Claude Code's auto-memory works inside agent containers. The settings are already configured (`CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0'`), the `.claude` directory persists on the volume, and `settingSources: ['project', 'user']` is set. - -**Files:** -- Check: `nanoclaw/src/container-runner.ts:124-140` (settings.json written to .claude) -- Check: Volume mount at `/home/node/.claude` - -**Step 1: Test auto-memory** - -Start a Warren session and ask the agent: "Remember that I prefer TypeScript over JavaScript for new code." End the session. Start a new session and ask: "What are my preferences?" If the agent remembers, auto-memory works. - -**Step 2: If auto-memory doesn't work, check paths** - -The SDK writes memory to `~/.claude/projects//memory/MEMORY.md`. Inside the container, cwd is `/workspace/group`, so the memory file would be at `/home/node/.claude/projects/-workspace-group/memory/MEMORY.md`. Verify this file exists on the volume after a session. - -If the file doesn't exist, the `query()` SDK might not support auto-memory. In that case, we'd need to investigate the SDK docs or add a manual memory mechanism. Document findings either way. - -**Step 3: Commit (if changes needed)** - -```bash -cd nanoclaw -git add -git commit -m "fix: ensure auto-memory works in agent containers" -``` - ---- - -### Task 6: Integration test — end to end - -Deploy and verify the full pipeline works. - -**Step 1: Push both repos** - -```bash -cd nanoclaw && git push origin main -cd warren && git -C nanoclaw pull origin main && git add nanoclaw && git commit -m "chore: update nanoclaw submodule" && git push origin main -``` - -**Step 2: Redeploy via Dokploy** - -Trigger a redeploy. Watch the nanoclaw logs for the `DinD volume mapping detected` message to confirm startup. - -**Step 3: Test tool activity stream** - -Start a new Warren session. Send a prompt that triggers multiple tools, e.g.: "Read the README.md in this workspace and summarize it." - -Expected in R1 UI: -``` -[TOOL] Read README.md -[assistant response text] -``` - -**Step 4: Test rapid tool sequences** - -Send: "Search for all TypeScript files and count the lines in each." - -Expected: Multiple `[TOOL]` one-liners scroll by in real time, followed by the result. - -**Step 5: Verify no regressions** - -- Text messages still render correctly -- Results still show with `msg-result` styling -- Status dot still updates (working → waiting) -- Sessions still persist and can be reopened diff --git a/docs/plans/2026-02-21-r1-workflow-engine-design.md b/docs/plans/2026-02-21-r1-workflow-engine-design.md deleted file mode 100644 index 0e3d692..0000000 --- a/docs/plans/2026-02-21-r1-workflow-engine-design.md +++ /dev/null @@ -1,148 +0,0 @@ -# R1 Workflow Engine Design - -## Problem - -Using the Rabbit R1 for development work is freeform — every interaction is an -unstructured chat session. There's no way to trigger predefined multi-step -workflows, no visibility into step-by-step progress on the 240x282 screen, and -no structured failure handling. The R1 should be a control surface for -steering autonomous dev work, not a code editor. - -## Goals - -- One-tap multi-step workflows from the R1 command palette (deploy, PR review, - run tests) -- Real-time step progress visible on the small screen -- Agent-planned workflows get the same structured UI as templates -- Auto-continue by default; pause only on failure -- No new backend infrastructure — leverage existing session/message/SSE flow - -## Design - -### Workflow Templates - -Templates live in Warren's `config.yaml` under a new `workflows` key: - -```yaml -workflows: - deploy: - label: "Deploy" - steps: - - "Pull latest from main" - - "Run the test suite" - - "If tests pass, push to production" - - "Report deploy status" - - pr-review: - label: "Review PR" - input: "pr_url" - steps: - - "Fetch the PR diff and description" - - "Review for bugs, security issues, and style" - - "Post a summary of findings" - - run-tests: - label: "Run Tests" - steps: - - "Run the full test suite" - - "Report pass/fail counts and any failures" -``` - -Each step is a natural language instruction the agent interprets and executes. -The `input` field means the R1 prompts for a value before starting. - -When triggered, Warren composes a single prompt from the steps and sends it to -the agent with a preamble instructing it to report progress after each step and -stop on failure. - -Templates are deliberately simple — no DAGs, no conditionals. The agent handles -sequencing and error decisions. A template is a structured prompt. - -### Step Progress Protocol - -The agent reports progress using a text convention in `send_message`: - -``` -[STEP:1/4:done] Created auth module -[STEP:2/4:running] Writing tests -[STEP:3/4:pending] Create PR -[STEP:4/4:pending] Run CI -``` - -Statuses: `pending`, `running`, `done`, `failed`. - -This is a convention, not a new IPC type. The R1 UI parses these from regular -chat messages. Messages without the prefix render as normal chat. The agent -learns this convention from its system prompt / CLAUDE.md. - -Agent-planned workflows use the same protocol. When the agent generates a plan -(via brainstorming/writing-plans skills), it reports steps in the same format, -giving the R1 identical rendering for both template and freeform workflows. - -### R1 UI Changes - -**Command palette** gains a Workflows category alongside existing Quick Actions -and Templates. Tapping a workflow starts a session with the composed prompt. - -**Active workflow view** — chat view gets a compact step progress header: - -``` -+--------------------------+ -| <- DEPLOY [STOP] | -| +----------------------+ | -| | * Pull latest | | -| | > Running tests... | | -| | o Push to prod | | -| | o Report status | | -| +----------------------+ | -| | -| Running test suite... | -| 142 passed, 0 failed | -| | -| > _ [CMD] | -+--------------------------+ -``` - -Step list is compact, fitting the 240x282 screen. Agent messages appear below. -The user can type to intervene at any point. - -No polling needed — the existing SSE stream pushes events in real-time. - -### Failure Handling - -When a step fails, the agent sends `[STEP:2/4:failed] Tests failed: 3 errors` -and stops. The R1 shows the failure in red. The user can: - -- **Type a response** — "skip tests and deploy anyway" or "fix the failing tests" -- **Tap [STOP]** — abort the workflow - -The agent resumes based on the user's response. No retry machinery — it's a -conversation with structure. - -## Changes by Component - -| Component | Change | -|---|---| -| `config.yaml` / `config.py` | New `workflows` section with named templates | -| `app.py` | Workflows exposed via existing internal config endpoints | -| `creation/js/app.js` | Parse `[STEP:...]` messages, render step list | -| `creation/index.html` | Workflow progress header in chat view | -| `creation/css/styles.css` | Step list styling (done/running/pending/failed) | -| Agent CLAUDE.md | Step reporting convention instructions | -| NanoClaw container skills | Optional: workflow execution skill | -| Command palette | New "Workflows" category from config | - -## What Doesn't Change - -- No new backend endpoints — workflows are structured prompts via existing - session/message flow -- No new IPC types — step progress goes through existing `send_message` -- No new MCP tools — agent already has everything it needs -- SSE streaming already works — no polling -- NanoClaw container-runner, IPC watcher unchanged - -## Key Insight - -A workflow is a well-structured prompt plus a UI convention for rendering -progress. The agent is already capable of multi-step execution. This design -adds structure and visibility, not new capabilities. diff --git a/docs/plans/2026-02-21-r1-workflow-engine-plan.md b/docs/plans/2026-02-21-r1-workflow-engine-plan.md deleted file mode 100644 index 93a69a5..0000000 --- a/docs/plans/2026-02-21-r1-workflow-engine-plan.md +++ /dev/null @@ -1,676 +0,0 @@ -# R1 Workflow Engine Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add structured multi-step workflows to the R1 Creation UI — template-based or agent-planned — with real-time step progress rendering on the 240x282 screen. - -**Architecture:** Workflows are structured prompts composed from template steps in `config.yaml`. Step progress is a text convention (`[STEP:n/total:status]`) embedded in regular `send_message` calls, parsed client-side. No new backend infrastructure, IPC types, or MCP tools needed. - -**Tech Stack:** Python (FastAPI/Pydantic), vanilla JS (R1 Creation), CSS, YAML config - ---- - -### Task 1: Add Workflow Config Model - -**Files:** -- Modify: `server/src/warren/config.py:29-43` -- Test: `server/tests/test_config.py` (create) - -**Step 1: Write the test** - -Create `server/tests/test_config.py`: - -```python -import pytest -from warren.config import WarrenConfig, WorkflowConfig - - -def test_workflow_config_from_yaml(): - data = { - "workflows": { - "deploy": { - "label": "Deploy", - "steps": [ - "Pull latest from main", - "Run the test suite", - "Push to production", - ], - }, - "pr-review": { - "label": "Review PR", - "input": "pr_url", - "steps": [ - "Fetch the PR diff", - "Review for bugs and style", - ], - }, - } - } - config = WarrenConfig(**data) - assert len(config.workflows) == 2 - assert config.workflows["deploy"].label == "Deploy" - assert len(config.workflows["deploy"].steps) == 3 - assert config.workflows["deploy"].input is None - assert config.workflows["pr-review"].input == "pr_url" - - -def test_workflow_config_defaults_empty(): - config = WarrenConfig() - assert config.workflows == {} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd server && uv run pytest tests/test_config.py -v` -Expected: FAIL — `WorkflowConfig` not defined - -**Step 3: Write minimal implementation** - -In `server/src/warren/config.py`, add before `CommandsConfig`: - -```python -class WorkflowConfig(BaseModel): - label: str - steps: list[str] - input: str | None = None -``` - -Add to `WarrenConfig`: - -```python -workflows: dict[str, WorkflowConfig] = {} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd server && uv run pytest tests/test_config.py -v` -Expected: PASS - -**Step 5: Lint** - -Run: `cd server && uv run ruff check src/ tests/` -Expected: Clean - -**Step 6: Commit** - -```bash -git add server/src/warren/config.py server/tests/test_config.py -git commit -m "feat: add WorkflowConfig model to config" -``` - ---- - -### Task 2: Expose Workflows in Commands Endpoint - -**Files:** -- Modify: `server/src/warren/app.py:313-321` (the `list_commands` endpoint) -- Modify: `server/tests/test_app.py` - -**Step 1: Write the test** - -Add to `server/tests/test_app.py` in the appropriate test class (or create one): - -```python -@pytest.mark.asyncio -async def test_commands_include_workflows(client, app): - app.state.config.workflows = { - "deploy": WorkflowConfig( - label="Deploy", - steps=["Pull latest", "Run tests", "Push"], - ), - } - resp = await client.get("/commands", headers=auth_headers()) - assert resp.status_code == 200 - data = resp.json() - assert "workflows" in data - assert len(data["workflows"]) == 1 - wf = data["workflows"][0] - assert wf["name"] == "deploy" - assert wf["label"] == "Deploy" - assert wf["steps"] == ["Pull latest", "Run tests", "Push"] - assert wf["input"] is None -``` - -Import `WorkflowConfig` at the top of the test file. - -**Step 2: Run test to verify it fails** - -Run: `cd server && uv run pytest tests/test_app.py::TestClassName::test_commands_include_workflows -v` -Expected: FAIL — no `workflows` key in response - -**Step 3: Modify the endpoint** - -In `app.py`, modify the `list_commands` endpoint to include workflows: - -```python -@app.get("/commands") -async def list_commands(request: Request, _token: str = Depends(require_auth)): - config = request.app.state.config - return { - "actions": [{"label": a.label, "prompt": a.prompt} for a in config.commands.actions], - "templates": [ - {"label": t.label, "prompt": t.prompt} for t in config.commands.templates - ], - "workflows": [ - { - "name": name, - "label": wf.label, - "steps": wf.steps, - "input": wf.input, - } - for name, wf in config.workflows.items() - ], - } -``` - -**Step 4: Run test to verify it passes** - -Run: `cd server && uv run pytest tests/test_app.py -v` -Expected: PASS (all existing tests still pass too) - -**Step 5: Lint** - -Run: `cd server && uv run ruff check src/ tests/` - -**Step 6: Commit** - -```bash -git add server/src/warren/app.py server/tests/test_app.py -git commit -m "feat: expose workflows in /commands endpoint" -``` - ---- - -### Task 3: Parse Step Progress in Chat Messages - -**Files:** -- Modify: `creation/js/app.js:279-325` (the `handleEvent` function) - -This is the core UI change. When a `text` event contains `[STEP:n/total:status]` lines, we extract them and update a step tracker instead of rendering them as regular chat messages. - -**Step 1: Add step state and parser** - -At the top of the IIFE in `app.js` (after the existing state declarations around line 14), add: - -```javascript -let workflowSteps = []; // [{index, total, status, text}] -``` - -Add a parser function after the `esc()` helper (around line 45): - -```javascript -/** Parse [STEP:n/total:status] lines from a message, return {steps, rest}. */ -function parseSteps(text) { - const stepRegex = /^\[STEP:(\d+)\/(\d+):(pending|running|done|failed)\]\s*(.*)$/; - const lines = text.split('\n'); - const steps = []; - const rest = []; - for (const line of lines) { - const m = line.match(stepRegex); - if (m) { - steps.push({ index: parseInt(m[1]), total: parseInt(m[2]), status: m[3], text: m[4] }); - } else if (line.trim()) { - rest.push(line); - } - } - return { steps, rest: rest.join('\n') }; -} -``` - -**Step 2: Modify handleEvent to detect steps** - -In `handleEvent`, modify the `"text"` case (line 281-283): - -```javascript -case "text": { - flushToolBuffer(); - const { steps, rest } = parseSteps(event.content); - if (steps.length > 0) { - workflowSteps = steps; - renderStepProgress(); - } - if (rest) { - appendTruncatedMsg("msg-text", rest); - } - break; -} -``` - -**Step 3: Add renderStepProgress function** - -After the `handleEvent` function, add: - -```javascript -function renderStepProgress() { - let container = document.getElementById("step-progress"); - if (!container) return; - container.textContent = ""; - if (workflowSteps.length === 0) { - container.classList.add("hidden"); - return; - } - container.classList.remove("hidden"); - workflowSteps.forEach((step) => { - const el = document.createElement("div"); - el.className = "step-item step-" + step.status; - const icon = step.status === "done" ? "*" - : step.status === "running" ? ">" - : step.status === "failed" ? "!" - : "o"; - el.textContent = icon + " " + step.text; - container.appendChild(el); - }); -} -``` - -**Step 4: Clear steps when opening a new session** - -In `openSession` (line 192), add `workflowSteps = [];` after clearing chat: - -```javascript -async function openSession(session) { - currentSessionId = session.id; - chatTitle.textContent = session.name; - chatMessages.textContent = ""; - workflowSteps = []; - // ... -``` - -Also call `renderStepProgress()` after loading message history (line 212), so steps from history are displayed. - -**Step 5: Test manually** - -No automated test for UI parsing, but verify by: -- Deploy and send a message containing `[STEP:1/3:done] Pulled latest\n[STEP:2/3:running] Running tests\n[STEP:3/3:pending] Push to prod` -- The step header should appear with done/running/pending indicators - -**Step 6: Commit** - -```bash -git add creation/js/app.js -git commit -m "feat: parse [STEP:] progress from chat messages" -``` - ---- - -### Task 4: Step Progress UI (HTML + CSS) - -**Files:** -- Modify: `creation/index.html:27-42` (chat view) -- Modify: `creation/css/styles.css` (add step styles) - -**Step 1: Add step progress container to chat view** - -In `creation/index.html`, add a `step-progress` div between the chat header and chat messages. Replace the chat view section (lines 27-42): - -```html - -
-
- - - - -
- -
- -
-``` - -**Step 2: Add CSS for step progress** - -Append to `creation/css/styles.css`: - -```css -/* Workflow step progress */ -.step-progress { - padding: 4px 8px; - border-bottom: 1px solid #0a6e0a; - max-height: 80px; - overflow-y: auto; -} - -.step-progress.hidden { - display: none; -} - -.step-progress::-webkit-scrollbar { - display: none; -} - -.step-item { - font-size: 10px; - padding: 1px 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.step-done { - color: #0a6e0a; -} - -.step-running { - color: #33ff33; - text-shadow: 0 0 6px rgba(51, 255, 51, 0.5); -} - -.step-pending { - color: #0a4e0a; -} - -.step-failed { - color: #ff3333; - text-shadow: 0 0 4px rgba(255, 51, 51, 0.3); -} -``` - -**Step 3: Verify HTML renders** - -Load the creation in a browser. The step-progress div should be hidden by default. When steps are received via SSE, they should appear between the header and messages. - -**Step 4: Commit** - -```bash -git add creation/index.html creation/css/styles.css -git commit -m "feat: step progress header UI in chat view" -``` - ---- - -### Task 5: Workflow Category in Command Palette - -**Files:** -- Modify: `creation/js/app.js:475-548` (the `openPalette` function) - -**Step 1: Add workflow items to palette** - -In `openPalette()`, after the Templates section (line 520) and before the Recents section (line 522), add a Workflows section: - -```javascript -// Workflows -if (cmds.workflows && cmds.workflows.length > 0) { - const header = document.createElement("div"); - header.className = "palette-section"; - header.textContent = "WORKFLOWS"; - paletteList.appendChild(header); - - cmds.workflows.forEach((w) => { - paletteItems.push({ - type: "workflow", - label: w.label, - steps: w.steps, - input: w.input, - name: w.name, - }); - const el = document.createElement("div"); - el.className = "palette-item"; - el.textContent = w.label; - el.addEventListener("click", () => selectPaletteItem(paletteItems.length - 1)); - paletteList.appendChild(el); - }); -} -``` - -**Step 2: Handle workflow selection in selectPaletteItem** - -In `selectPaletteItem()` (line 566), add handling for the `workflow` type. Before the existing `if (item.type === "template")` block (line 576): - -```javascript -if (item.type === "workflow") { - if (item.input) { - // Workflow needs user input — prefill msg input with placeholder - msgInput.value = ""; - msgInput.placeholder = item.input + "..."; - msgInput.dataset.workflowSteps = JSON.stringify(item.steps); - msgInput.dataset.workflowName = item.name; - msgInput.focus(); - } else { - // No input needed — compose and send immediately - const prompt = composeWorkflowPrompt(item.steps); - msgInput.value = prompt; - sendMsg(); - } - return; -} -``` - -**Step 3: Handle workflow selection from sessions view** - -In `createSessionFromPalette()` (line 595), add handling for workflows before the existing try block: - -```javascript -if (item.type === "workflow") { - if (item.input) { - // Go to new session view with workflow context - showView("new"); - initialMsg.value = ""; - initialMsg.placeholder = item.input + "..."; - initialMsg.dataset.workflowSteps = JSON.stringify(item.steps); - initialMsg.dataset.workflowName = item.name; - initialMsg.focus(); - } else { - // Create session with composed workflow prompt - try { - let repoName = ""; - if (backendType !== "nanoclaw") { - const repos = await Warren.fetchRepos(); - repoName = Object.keys(repos)[0]; - if (!repoName) { appendRawMsg("msg-error", "No repos configured"); return; } - } - const prompt = composeWorkflowPrompt(item.steps); - const data = await Warren.createSession(repoName, prompt); - openSession({ id: data.session_id, name: data.name, repo: data.repo }); - } catch (err) { - appendRawMsg("msg-error", "Failed to create session"); - } - } - return; -} -``` - -**Step 4: Add composeWorkflowPrompt helper** - -Add this helper function near the other helpers (after `parseSteps`): - -```javascript -function composeWorkflowPrompt(steps) { - const preamble = "Execute this workflow step by step. After completing each step, report progress using the format: [STEP:n/total:status] description (status: pending, running, done, failed). Stop on failure and report the error.\n\nSteps:"; - const numbered = steps.map((s, i) => (i + 1) + ". " + s).join("\n"); - return preamble + "\n" + numbered; -} -``` - -**Step 5: Handle sendMsg for workflows with input** - -In `sendMsg()` (line 625), check if the input has workflow context and compose the prompt: - -After `const text = msgInput.value.trim();` and before `if (!text || !currentSessionId) return;`, add: - -```javascript -const wfSteps = msgInput.dataset.workflowSteps; -if (wfSteps) { - const steps = JSON.parse(wfSteps); - const prompt = composeWorkflowPrompt(steps).replace('{' + (msgInput.dataset.workflowInput || 'input') + '}', text); - msgInput.dataset.workflowSteps = ""; - msgInput.dataset.workflowName = ""; - msgInput.placeholder = "> _"; - msgInput.value = prompt + "\n\nInput: " + text; -} -``` - -Actually, simpler approach — when the user provides input, prepend it to the workflow prompt: - -```javascript -const wfSteps = msgInput.dataset.workflowSteps; -if (wfSteps) { - const steps = JSON.parse(wfSteps); - msgInput.value = composeWorkflowPrompt(steps) + "\n\nInput: " + text; - delete msgInput.dataset.workflowSteps; - delete msgInput.dataset.workflowName; - msgInput.placeholder = "> _"; -} -``` - -**Step 6: Commit** - -```bash -git add creation/js/app.js -git commit -m "feat: workflow category in command palette + launcher" -``` - ---- - -### Task 6: Agent Step Reporting Convention - -**Files:** -- Create: `nanoclaw/container/skills/workflow-steps/SKILL.md` - -This skill teaches the agent the step reporting convention. It's loaded when the agent is running inside NanoClaw containers. - -**Step 1: Create the skill file** - -```markdown ---- -name: workflow-steps -description: Report step-by-step progress for multi-step workflows. Use when executing a workflow with numbered steps, or when you plan and execute a multi-step task. ---- - -# Workflow Step Progress - -When executing multi-step work, report progress after each step using the `send_message` tool with this format: - -``` -[STEP:1/4:done] Pulled latest from main -[STEP:2/4:running] Running test suite -[STEP:3/4:pending] Push to production -[STEP:4/4:pending] Report deploy status -``` - -## Format - -`[STEP:{current}/{total}:{status}] {description}` - -**Statuses:** `pending`, `running`, `done`, `failed` - -## Rules - -- Send ALL steps in every progress update (not just the current one) -- Mark completed steps as `done`, current step as `running`, future steps as `pending` -- On failure: mark the step as `failed`, stop execution, describe the error -- Send progress via `send_message` after each step completes -- You can include additional text after the step block — it renders as normal chat - -## Example: Success - -``` -[STEP:1/3:done] Cloned repository -[STEP:2/3:done] Tests passed (142/142) -[STEP:3/3:running] Creating pull request -``` - -## Example: Failure - -``` -[STEP:1/3:done] Cloned repository -[STEP:2/3:failed] Tests failed: 3 errors in auth module -[STEP:3/3:pending] Create pull request - -Test failures: -- test_login_redirect: AssertionError -- test_token_refresh: TimeoutError -- test_logout: 404 response -``` - -## Agent-Planned Workflows - -When a user gives you a complex task without predefined steps, you can plan your own steps and report them the same way. Create your step list, then report progress as you execute. -``` - -**Step 2: Verify skill is loadable** - -Run: `ls -la nanoclaw/container/skills/workflow-steps/SKILL.md` -Expected: File exists with correct content - -**Step 3: Commit** - -```bash -git add nanoclaw/container/skills/workflow-steps/SKILL.md -git commit -m "feat: add workflow-steps skill for agent step reporting" -``` - ---- - -### Task 7: Add Sample Workflows to Config - -**Files:** -- Modify: `server/src/warren/config.py` (no changes needed — model already supports it) -- Note: No `config.yaml` exists in repo (it's deployment-specific) - -This task documents the sample config and verifies the end-to-end flow works. - -**Step 1: Add sample workflows to config documentation** - -Update the `CLAUDE.md` deploy section or the design doc with example config: - -```yaml -workflows: - deploy: - label: "Deploy" - steps: - - "Pull latest from main" - - "Run the test suite" - - "If tests pass, push to production" - - "Report deploy status" - pr-review: - label: "Review PR" - input: "pr_url" - steps: - - "Fetch the PR diff and description" - - "Review for bugs, security issues, and style" - - "Post a summary of findings" - run-tests: - label: "Run Tests" - steps: - - "Run the full test suite" - - "Report pass/fail counts and any failures" -``` - -**Step 2: Run full test suite** - -Run: `cd server && uv run pytest tests/ -v` -Expected: All tests pass - -**Step 3: Build nanoclaw** - -Run: `cd nanoclaw && npm run build` -Expected: Clean compilation - -**Step 4: Commit** - -```bash -git add -A -git commit -m "feat: R1 workflow engine — complete implementation" -``` - ---- - -## Verification Checklist - -1. `cd server && uv run ruff check src/ tests/` — lint clean -2. `cd server && uv run pytest tests/ -v` — all tests pass -3. `cd nanoclaw && npm run build` — compiles clean -4. Deploy and open R1: - - Command palette shows WORKFLOWS section - - Tap a workflow → creates session with composed prompt - - Agent sends `[STEP:...]` messages → step header appears - - Failed step shows red, agent stops - - User can type to intervene or tap [STOP] -5. Agent-planned workflows: ask agent a complex task → it uses workflow-steps skill to report steps in same format diff --git a/docs/plans/2026-02-22-code-reduction-design.md b/docs/plans/2026-02-22-code-reduction-design.md deleted file mode 100644 index 9f0b17c..0000000 --- a/docs/plans/2026-02-22-code-reduction-design.md +++ /dev/null @@ -1,139 +0,0 @@ -# Code Reduction + R1 Test Harness Design - -## Context - -Now that PTT voice is the primary interaction method and NanoClaw is the only -production backend, Warren carries ~860 lines of dead code: the DirectAdapter -subprocess system, unused Creation JS modules, and backend-switching logic. - -Additionally, there's no way to test the full R1 pipeline without a physical -device. An E2E test harness that emulates both R1 interfaces (voice PTT and -Creation WebView) against a local docker-compose stack would catch regressions -automatically. - -## Part 1: Surgical Code Removal - -### 1A. Remove DirectAdapter + ClaudeRunner - -**Delete entirely:** -- `server/src/warren/adapters/direct.py` (118 lines) -- `server/src/warren/claude_runner.py` (148 lines) -- `server/tests/test_claude_runner.py` (119 lines) - -**Modify `server/tests/test_adapters.py`:** -- Delete `TestDirectAdapter` class (lines 37-159) -- Keep `TestNanoClawAdapter` and message/event unit tests - -**Simplify `server/src/warren/app.py`:** -- Remove `from warren.adapters.direct import DirectAdapter` import -- Simplify `create_adapter()` to always create NanoClawAdapter (no switch) -- Remove `backend != "nanoclaw"` special case in `create_session` (line 480-481) - -**Fix test fixtures** in 6 files that use DirectAdapter as a mock target: -- `test_app.py`, `test_streaming.py`, `test_integration.py` -- `test_voice.py`, `test_voice_integration.py`, `test_voice_pairing.py` -- Replace `DirectAdapter(...)` with `NanoClawAdapter(ipc_dir=...)` in fixtures - -### 1B. Remove Dead Creation JS - -**Delete entirely:** -- `creation/js/probe.js` (234 lines) -- network diagnostics, triple-tap activated -- `creation/js/hardware.js` (45 lines) -- unimplemented R1 STT button mapping - -**Modify `creation/index.html`:** -- Remove ` -``` - -```html - -``` - -The scripts section should become: - -```html - - - -``` - -**Step 3: Remove Probe attachment from app.js** - -In `creation/js/app.js`, delete lines 999-1003: - -```javascript - // Attach API probe (triple-tap "WARREN://" title to activate) - if (typeof Probe !== "undefined") { - const probeTarget = document.querySelector("#view-sessions .title"); - if (probeTarget) Probe.attach(probeTarget, sessionList); - } -``` - -**Step 4: Remove Hardware references from app.js** - -In `creation/js/app.js`, the `Hardware.bind(...)` calls (lines 833-959) reference the deleted `Hardware` global. Since `hardware.js` defined the `Hardware` object, these calls will throw `ReferenceError`. - -However — looking more carefully, `Hardware.bind` is used for scroll wheel, side button, and long-press PTT. These are still valuable for the R1 physical buttons. The `hardware.js` file defines the Hardware object that translates raw DOM events (wheel, pointerdown, etc.) into named actions. - -**Actually — re-read hardware.js before deleting.** Let me check what it does: - -The `Hardware` object maps: -- `scrollUp` / `scrollDown` — scroll wheel events → navigate session list, palette, chat -- `sideClick` — R1 side button → select session, send message, etc. -- `longPressStart` / `longPressEnd` — long-press side button → STT recording - -**The scroll wheel and side button bindings are actively used on the R1.** Only the STT (longPress) part is dead code. - -**Revised plan:** Keep `hardware.js`. Only delete `probe.js`. - -Updated step 1: -```bash -rm creation/js/probe.js -``` - -Updated step 2 — only remove probe.js script tag from index.html: -```html - - -``` - -Step 3 stays the same (remove Probe attachment from app.js). - -Step 4 is no longer needed — hardware.js stays. - -**Step 5: Verify the creation page loads** - -Open `creation/index.html` in a browser or check no JS errors: -```bash -cd server && uv run python -c " -from pathlib import Path -html = Path('../creation/index.html').read_text() -assert 'probe.js' not in html -assert 'hardware.js' in html -print('OK: probe.js removed, hardware.js kept') -" -``` - -**Step 6: Commit** - -```bash -git add -A && git commit -m "refactor: remove dead probe.js from Creation UI" -``` - ---- - -### Task 4: Clean up backend config and switching logic - -**Files:** -- Modify: `server/src/warren/config.py` -- Modify: `server/src/warren/app.py` -- Modify: `creation/js/api.js` -- Modify: `creation/js/app.js` - -**Step 1: Remove `backend` and `claude_bin` from Settings** - -In `server/src/warren/config.py`, delete these two lines from the `Settings` class: - -```python - claude_bin: str = "claude" - backend: str = "direct" -``` - -The Settings class should have these fields remaining: -```python -class Settings(BaseSettings): - model_config = {"env_prefix": "WARREN_"} - - host: str = "0.0.0.0" - port: int = 4030 - db: str = "warren.db" - config: str = "config.yaml" - anthropic_api_key: str = "" - admin_token: str = "" - data_dir: str = "data" - nanoclaw_db: str = "" - nanoclaw_ipc_dir: str = "" - voice_enabled: bool = False - voice_port: int = 443 - internal_secret: str = "" -``` - -**Step 2: Simplify `create_adapter()` in app.py** - -Replace the entire `create_adapter` function (lines 72-85) with: - -```python -def create_adapter(settings: Settings, config): - """Create the NanoClaw backend adapter.""" - from warren.adapters.nanoclaw import NanoClawAdapter - - db_path, ipc_dir = _nanoclaw_paths(settings, config) - return NanoClawAdapter(ipc_dir=ipc_dir, db_path=db_path) -``` - -Also remove the DirectAdapter import at the top of app.py (line 21): -```python -from warren.adapters.direct import DirectAdapter -``` - -**Step 3: Remove `/backend` endpoint from app.py** - -Delete the `/backend` endpoint (lines 314-316): - -```python - @app.get("/backend") - async def get_backend(request: Request, _token: str = Depends(require_auth)): - return {"backend": request.app.state.settings.backend} -``` - -**Step 4: Remove `backend != "nanoclaw"` guards in app.py** - -In `create_session` (around line 480), delete this line: -```python - elif repo_name and settings.backend != "nanoclaw": - raise HTTPException(status_code=404, detail=f"Unknown repo: {repo_name}") -``` - -In `monitor_health` (around line 637), simplify to always check nanoclaw: -```python - @app.get("/monitor/health") - async def monitor_health( - request: Request, - _token: str = Depends(require_auth), - ): - import os - - settings = request.app.state.settings - result = {"warren": {"status": "ok"}} - - db_path, _ = _nanoclaw_paths(settings, request.app.state.config) - if os.path.exists(db_path): - result["nanoclaw"] = {"status": "ok"} - else: - result["nanoclaw"] = { - "status": "unavailable", - "detail": "DB not found", - } - - return result -``` - -In `monitor_tasks` (around line 664), remove the `backend != "nanoclaw"` guard: -```python - @app.get("/monitor/tasks") - async def monitor_tasks( - request: Request, - _token: str = Depends(require_auth), - ): - settings = request.app.state.settings - db_path, _ = _nanoclaw_paths(settings, request.app.state.config) - from warren.nanoclaw_db import NanoClawDbReader - - reader = NanoClawDbReader(db_path) - tasks = await reader.list_tasks() - return {"tasks": tasks} -``` - -In `pause_task` (around line 680), remove the guard: -```python - @app.post("/monitor/tasks/{task_id}/pause") - async def pause_task( - task_id: str, - request: Request, - _token: str = Depends(require_auth), - ): - settings = request.app.state.settings - _, ipc_dir = _nanoclaw_paths(settings, request.app.state.config) - from warren.ipc import write_ipc_task - - write_ipc_task( - ipc_dir=ipc_dir, - group="warren", - task_id=f"pause-{task_id}", - payload={ - "type": "pause_task", - "task_id": task_id, - "owner_group": "warren", - }, - ) - return {"status": "paused", "task_id": task_id} -``` - -Same for `resume_task` — remove the guard. - -**Step 5: Remove `fetchBackend` from api.js** - -In `creation/js/api.js`, delete the function (lines 49-51): -```javascript - async function fetchBackend() { - return _fetch("/backend"); - } -``` - -And remove `fetchBackend` from the return object (line 153): -```javascript - fetchBackend, -``` - -**Step 6: Remove `backendType` from app.js** - -In `creation/js/app.js`, make these changes: - -1. Delete line 13: `let backendType = "direct";` - -2. Replace the `initialize` function (lines 1005-1027) — remove the fetchBackend call: -```javascript - async function initialize() { - const result = Warren.initFromUrl(); - if (result.ok) { - showView("sessions"); - return; - } - sessionList.textContent = ""; - const empty = document.createElement("div"); - empty.className = "empty-state"; - empty.textContent = "ERR: " + result.reason; - sessionList.appendChild(empty); - - const debug = document.createElement("div"); - debug.className = "empty-state"; - debug.style.fontSize = "10px"; - debug.style.wordBreak = "break-all"; - debug.textContent = window.location.href; - sessionList.appendChild(debug); - } -``` - -3. In `createSessionFromPalette` (around line 690), replace the workflow branch that checks backendType: - -```javascript - // BEFORE (lines 699-711): - } else { - try { - let repoName = ""; - if (backendType !== "nanoclaw") { - const repos = await Warren.fetchRepos(); - repoName = Object.keys(repos)[0]; - if (!repoName) { appendRawMsg("msg-error", "No repos configured"); return; } - } - const prompt = composeWorkflowPrompt(item.steps); - const data = await Warren.createSession(repoName, prompt); - openSession({ id: data.session_id, name: data.name, repo: data.repo }); - } catch (err) { - appendRawMsg("msg-error", "Failed to create session"); - } - } - - // AFTER: - } else { - try { - const prompt = composeWorkflowPrompt(item.steps); - const data = await Warren.createSession("", prompt); - openSession({ id: data.session_id, name: data.name, repo: data.repo }); - } catch (err) { - appendRawMsg("msg-error", "Failed to create session"); - } - } -``` - -4. Same pattern for the action/recent branch (around lines 728-742): -```javascript - // BEFORE: - try { - let repoName = ""; - if (backendType !== "nanoclaw") { - const repos = await Warren.fetchRepos(); - repoName = Object.keys(repos)[0]; - if (!repoName) { - appendRawMsg("msg-error", "No repos configured"); - return; - } - } - const data = await Warren.createSession(repoName, item.prompt); - - // AFTER: - try { - const data = await Warren.createSession("", item.prompt); -``` - -5. Simplify `loadRepos` (around line 772) — always hide repo picker: -```javascript - async function loadRepos() { - const repoLabel = repoSelect.previousElementSibling; - repoSelect.style.display = "none"; - if (repoLabel) repoLabel.style.display = "none"; - initialMsg.focus(); - } -``` - -6. In `startSession` (around line 811), remove the backendType guard: -```javascript - // BEFORE: - if (!repo && backendType !== "nanoclaw") return; - - // AFTER: - // (delete this line entirely — repo is always optional with nanoclaw) -``` - -7. In `handleEvent` case `"tool"` (around lines 313-329), remove the DirectAdapter branch: -```javascript - case "tool": { - flushToolBuffer(); - const label = event.tool - ? event.tool + " " + (event.summary || "") - : event.action || "tool"; - appendRawMsg("msg-tool", label); - break; - } -``` - -**Step 7: Run lint and tests** - -```bash -cd server && uv run ruff check src/ tests/ -cd server && uv run pytest tests/ -v -``` - -Expected: Lint clean, all tests pass. - -**Step 8: Verify no remaining references to removed code** - -```bash -grep -r "backendType\|fetchBackend\|backend.*direct\|DirectAdapter\|claude_runner\|ClaudeRunner\|claude_bin" server/src/ server/tests/ creation/ -``` - -Expected: No output (except possibly CLAUDE.md docs, which is fine). - -**Step 9: Commit** - -```bash -git add -A && git commit -m "refactor: remove backend switching logic (nanoclaw-only)" -``` - ---- - -### Task 5: Update tests for removed /backend endpoint and Settings fields - -**Files:** -- Modify: `server/tests/test_app.py` (if it tests `/backend`) -- Modify: `server/tests/test_monitor.py` (monitor tests may assume direct backend) -- Modify: `server/tests/test_config.py` (if it tests `backend` or `claude_bin` fields) - -**Step 1: Check and fix test_config.py** - -Search for tests that reference `backend` or `claude_bin` in Settings and remove/update them. - -```bash -cd server && grep -n "backend\|claude_bin" tests/test_config.py -``` - -Remove any tests that assert on `settings.backend` or `settings.claude_bin`. - -**Step 2: Check and fix test_monitor.py** - -The `monitor_app` fixture (line 19) doesn't set `backend`, so it defaults. Since we removed the `backend` field, the default is now always nanoclaw. The test `test_returns_empty_when_no_nanoclaw` (line 100) may need updating — it currently expects `{"tasks": []}` when `backend != "nanoclaw"`, but now monitor_tasks always tries to read the NanoClaw DB. If the DB doesn't exist, it should still return empty or handle gracefully. - -Check the monitor fixture uses the lifespan (which calls `create_adapter`), so it will now try to create a NanoClawAdapter. The fixture already has `tmp_path`, so the IPC dir will be created under tmp_path. But the NanoClaw DB won't exist, so `monitor_tasks` will try to read a non-existent file. - -Fix: ensure `NanoClawDbReader.list_tasks()` handles missing DB gracefully, OR update the test to expect the actual behavior. - -**Step 3: Run full test suite** - -```bash -cd server && uv run pytest tests/ -v -``` - -Fix any failures. - -**Step 4: Commit** - -```bash -git add -A && git commit -m "test: update tests for nanoclaw-only backend" -``` - ---- - -### Task 6: Create mock Claude CLI - -**Files:** -- Create: `tests/e2e/mock-claude` - -**Step 1: Write the mock claude script** - -Create `tests/e2e/mock-claude` — a shell script that mimics `claude -p "..." --output-format stream-json`: - -```bash -#!/bin/bash -# Mock Claude CLI for E2E testing. -# Reads -p argument and returns canned stream-json output. - -MESSAGE="" -while [[ $# -gt 0 ]]; do - case $1 in - -p) MESSAGE="$2"; shift 2 ;; - *) shift ;; - esac -done - -# Simulate short processing delay -sleep 0.5 - -# Emit stream-json lines matching real Claude CLI output format -echo '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I received your message: '"${MESSAGE}"'"}]}}' -sleep 0.2 -echo '{"type":"result","result":"Mock response complete.","session_id":"mock-session-001"}' -``` - -**Step 2: Make it executable** - -```bash -chmod +x tests/e2e/mock-claude -``` - -**Step 3: Test it locally** - -```bash -./tests/e2e/mock-claude -p "hello world" --output-format stream-json -``` - -Expected output: -``` -{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I received your message: hello world"}]}} -{"type":"result","result":"Mock response complete.","session_id":"mock-session-001"} -``` - -**Step 4: Commit** - -```bash -git add tests/e2e/mock-claude && git commit -m "test: add mock Claude CLI for E2E testing" -``` - ---- - -### Task 7: Create docker-compose test override - -**Files:** -- Create: `tests/e2e/docker-compose.test.yml` - -**Step 1: Write the test compose override** - -This overlays the base `docker-compose.yml` to: -- Mount mock-claude into the NanoClaw agent container image build -- Set test secrets -- Expose Warren on a fixed test port - -```yaml -# tests/e2e/docker-compose.test.yml -# Override for E2E testing — uses mock Claude CLI, no real API key needed. -# Usage: docker compose -f docker-compose.yml -f tests/e2e/docker-compose.test.yml up -d - -services: - warren: - environment: - - ANTHROPIC_API_KEY=sk-test-mock - - WARREN_ADMIN_TOKEN=test-admin-token - - WARREN_INTERNAL_SECRET=test-internal-secret - - WARREN_VOICE_ENABLED=true - - WARREN_VOICE_PORT=443 - ports: - - "14030:4030" - - nanoclaw: - environment: - - ANTHROPIC_API_KEY=sk-test-mock - - WARREN_INTERNAL_SECRET=test-internal-secret - volumes: - - ./tests/e2e/mock-claude:/usr/local/bin/claude:ro -``` - -**Step 2: Commit** - -```bash -git add tests/e2e/docker-compose.test.yml && git commit -m "test: add docker-compose test override for E2E" -``` - ---- - -### Task 8: Create E2E test conftest with docker-compose lifecycle - -**Files:** -- Create: `tests/e2e/__init__.py` -- Create: `tests/e2e/conftest.py` - -**Step 1: Write conftest.py** - -```python -"""E2E test fixtures — manages docker-compose lifecycle.""" - -import os -import secrets -import subprocess -import time - -import httpx -import pytest - -WARREN_URL = os.environ.get("WARREN_E2E_URL", "http://localhost:14030") -COMPOSE_FILES = [ - "docker-compose.yml", - "tests/e2e/docker-compose.test.yml", -] -PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - - -def _compose_cmd(*args: str) -> list[str]: - """Build a docker compose command with the right -f flags.""" - cmd = ["docker", "compose"] - for f in COMPOSE_FILES: - cmd.extend(["-f", os.path.join(PROJECT_ROOT, f)]) - cmd.extend(args) - return cmd - - -@pytest.fixture(scope="session") -def compose_stack(): - """Start docker-compose stack for the test session, tear down after.""" - # Build and start - subprocess.run(_compose_cmd("build"), check=True, cwd=PROJECT_ROOT) - subprocess.run( - _compose_cmd("up", "-d", "--wait"), - check=True, - cwd=PROJECT_ROOT, - ) - - # Wait for Warren health endpoint - deadline = time.time() + 60 - while time.time() < deadline: - try: - resp = httpx.get(f"{WARREN_URL}/health", timeout=2) - if resp.status_code == 200: - break - except httpx.ConnectError: - pass - time.sleep(1) - else: - # Dump logs on failure - subprocess.run(_compose_cmd("logs"), cwd=PROJECT_ROOT) - pytest.fail("Warren did not become healthy within 60 seconds") - - yield WARREN_URL - - # Tear down - subprocess.run(_compose_cmd("down", "-v"), cwd=PROJECT_ROOT) - - -@pytest.fixture -def warren_url(compose_stack) -> str: - """Return the Warren base URL.""" - return compose_stack - - -@pytest.fixture -def admin_headers() -> dict: - """Admin auth headers for test endpoints.""" - return {"X-Admin-Token": "test-admin-token"} - - -@pytest.fixture -def device_token(warren_url, admin_headers) -> str: - """Create a device token via pairing flow.""" - resp = httpx.get(f"{warren_url}/pair", headers=admin_headers) - assert resp.status_code == 200 - pairing_token = resp.json()["pairing_token"] - - resp = httpx.post( - f"{warren_url}/pair/confirm", - json={"pairing_token": pairing_token, "device_name": "e2e-test"}, - ) - assert resp.status_code == 200 - return resp.json()["device_token"] - - -@pytest.fixture -def auth_headers(device_token) -> dict: - """Device auth headers.""" - return {"Authorization": f"Bearer {device_token}"} -``` - -Also create the empty `__init__.py`: - -```python -``` - -**Step 2: Commit** - -```bash -git add tests/e2e/ && git commit -m "test: add E2E conftest with docker-compose lifecycle" -``` - ---- - -### Task 9: Create voice PTT E2E tests - -**Files:** -- Create: `tests/e2e/test_voice_e2e.py` - -**Step 1: Write voice E2E tests** - -```python -"""E2E tests for R1 voice PTT interface (OpenClaw WebSocket protocol).""" - -import json - -import pytest -import websockets.sync.client as ws_client - - -def _ws_url(warren_url: str) -> str: - """Convert http://host:port to ws://host:port/.""" - return warren_url.replace("http://", "ws://") + "/" - - -class TestVoiceHandshake: - def test_challenge_on_connect(self, warren_url): - """Server sends connect.challenge immediately after WebSocket open.""" - with ws_client.connect(_ws_url(warren_url)) as ws: - msg = json.loads(ws.recv(timeout=5)) - assert msg["type"] == "event" - assert msg["event"] == "connect.challenge" - assert "nonce" in msg["payload"] - - def test_full_handshake(self, warren_url, device_token): - """Challenge -> connect -> hello-ok with valid token.""" - with ws_client.connect(_ws_url(warren_url)) as ws: - challenge = json.loads(ws.recv(timeout=5)) - nonce = challenge["payload"]["nonce"] - - ws.send(json.dumps({ - "type": "req", - "id": "connect-1", - "method": "connect", - "params": { - "minProtocol": 3, - "maxProtocol": 3, - "auth": {"token": device_token}, - "client": {"id": "e2e-test", "version": "1.0", "platform": "test"}, - "role": "node", - "device": {"id": "e2e-device", "nonce": nonce}, - }, - })) - - hello = json.loads(ws.recv(timeout=5)) - assert hello["type"] == "res" - assert hello["ok"] is True - assert hello["payload"]["type"] == "hello-ok" - - def test_invalid_token_rejected(self, warren_url): - """Connect with bad token returns hello-error.""" - with ws_client.connect(_ws_url(warren_url)) as ws: - challenge = json.loads(ws.recv(timeout=5)) - nonce = challenge["payload"]["nonce"] - - ws.send(json.dumps({ - "type": "req", - "id": "connect-1", - "method": "connect", - "params": { - "minProtocol": 3, - "maxProtocol": 3, - "auth": {"token": "bad-token"}, - "client": {"id": "e2e-test", "version": "1.0", "platform": "test"}, - "role": "node", - "device": {"id": "e2e-device", "nonce": nonce}, - }, - })) - - hello = json.loads(ws.recv(timeout=5)) - assert hello["type"] == "res" - assert hello["ok"] is False - - -class TestVoiceChatSend: - def _handshake(self, ws, device_token: str) -> None: - """Helper: complete handshake.""" - challenge = json.loads(ws.recv(timeout=5)) - nonce = challenge["payload"]["nonce"] - ws.send(json.dumps({ - "type": "req", - "id": "connect-1", - "method": "connect", - "params": { - "minProtocol": 3, - "maxProtocol": 3, - "auth": {"token": device_token}, - "client": {"id": "e2e-test", "version": "1.0", "platform": "test"}, - "role": "node", - "device": {"id": "e2e-device", "nonce": "test"}, - }, - })) - hello = json.loads(ws.recv(timeout=5)) - assert hello["ok"] is True - - def test_chat_send_gets_ack(self, warren_url, device_token): - """Sending chat.send returns a chat-ack response.""" - with ws_client.connect(_ws_url(warren_url)) as ws: - self._handshake(ws, device_token) - - ws.send(json.dumps({ - "type": "req", - "id": "chat-1", - "method": "chat.send", - "params": { - "sessionKey": "main", - "message": "E2E test message", - "idempotencyKey": "e2e-idem-1", - }, - })) - - ack = json.loads(ws.recv(timeout=10)) - assert ack["type"] == "res" - assert ack["ok"] is True - assert ack["id"] == "chat-1" -``` - -**Step 2: Commit** - -```bash -git add tests/e2e/test_voice_e2e.py && git commit -m "test: add voice PTT E2E tests" -``` - ---- - -### Task 10: Create Creation UI E2E tests with Playwright - -**Files:** -- Create: `tests/e2e/test_creation_e2e.py` - -**Step 1: Install Playwright test dependency** - -```bash -pip install playwright pytest-playwright -playwright install chromium -``` - -**Step 2: Write Creation E2E tests** - -```python -"""E2E tests for R1 Creation UI at 240x282 viewport.""" - -import re - -import pytest -from playwright.sync_api import Page, expect - - -@pytest.fixture -def r1_page(page: Page, warren_url, device_token) -> Page: - """Navigate to Creation UI at R1 viewport size.""" - page.set_viewport_size({"width": 240, "height": 282}) - page.goto(f"{warren_url}/creation/?token={device_token}") - return page - - -class TestCreationLoads: - def test_session_list_renders(self, r1_page): - """Creation UI loads and shows session list or empty state.""" - expect(r1_page.locator("#view-sessions")).to_be_visible() - # Either sessions or "NO SESSIONS" text - r1_page.wait_for_selector("#session-list", timeout=5000) - - def test_connection_dot_green(self, r1_page): - """Connection indicator turns green when server is reachable.""" - dot = r1_page.locator("#connection-dot") - expect(dot).to_have_class(re.compile(r"dot-green")) - - def test_viewport_is_240x282(self, r1_page): - """Verify viewport matches R1 dimensions.""" - size = r1_page.viewport_size - assert size["width"] == 240 - assert size["height"] == 282 - - -class TestMonitorView: - def test_monitor_loads(self, r1_page): - """Clicking MON button shows the monitor view.""" - r1_page.click("#btn-monitor") - expect(r1_page.locator("#view-monitor")).to_have_class(re.compile(r"active")) - expect(r1_page.locator("#monitor-health")).to_be_visible() - - def test_monitor_shows_health(self, r1_page): - """Monitor view shows health status after refresh.""" - r1_page.click("#btn-monitor") - r1_page.click("#btn-monitor-refresh") - r1_page.wait_for_timeout(1000) - # Should show warren health status - health = r1_page.locator("#monitor-health") - expect(health).to_contain_text("warren") -``` - -**Step 3: Commit** - -```bash -git add tests/e2e/test_creation_e2e.py && git commit -m "test: add Creation UI E2E tests with Playwright" -``` - ---- - -### Task 11: Create full pipeline E2E test - -**Files:** -- Create: `tests/e2e/test_pipeline_e2e.py` - -**Step 1: Write pipeline E2E test** - -This test verifies the full round-trip: voice message → NanoClaw → mock agent → callback → Warren → SSE/WebSocket. - -```python -"""E2E test for full NanoClaw pipeline: voice → agent → callback → response.""" - -import json -import threading -import time - -import httpx -import websockets.sync.client as ws_client - - -def _ws_url(warren_url: str) -> str: - return warren_url.replace("http://", "ws://") + "/" - - -def _handshake(ws, device_token: str) -> None: - """Complete OpenClaw handshake.""" - challenge = json.loads(ws.recv(timeout=5)) - nonce = challenge["payload"]["nonce"] - ws.send(json.dumps({ - "type": "req", - "id": "connect-1", - "method": "connect", - "params": { - "minProtocol": 3, - "maxProtocol": 3, - "auth": {"token": device_token}, - "client": {"id": "e2e-test", "version": "1.0", "platform": "test"}, - "role": "node", - "device": {"id": "e2e-device", "nonce": nonce}, - }, - })) - hello = json.loads(ws.recv(timeout=5)) - assert hello["ok"] is True - - -class TestFullPipeline: - def test_voice_message_round_trip(self, warren_url, device_token, auth_headers): - """Voice chat.send → NanoClaw processes → response arrives via WebSocket.""" - with ws_client.connect(_ws_url(warren_url), close_timeout=5) as ws: - _handshake(ws, device_token) - - # Send a message via voice - ws.send(json.dumps({ - "type": "req", - "id": "chat-1", - "method": "chat.send", - "params": { - "sessionKey": "main", - "message": "Full pipeline test", - "idempotencyKey": "pipeline-1", - }, - })) - - # Collect events (ack + chat events) with timeout - events = [] - deadline = time.time() + 120 # 2 min for agent to complete - got_final = False - - while time.time() < deadline and not got_final: - try: - raw = ws.recv(timeout=10) - msg = json.loads(raw) - events.append(msg) - - # Check for final chat event - if ( - msg.get("type") == "event" - and msg.get("event") == "chat" - and msg.get("payload", {}).get("state") == "final" - ): - got_final = True - except TimeoutError: - continue - - # Verify we got an ack - acks = [e for e in events if e.get("type") == "res" and e.get("id") == "chat-1"] - assert len(acks) == 1, f"Expected 1 ack, got {len(acks)}: {acks}" - - # Verify we got at least one chat event - chat_events = [e for e in events if e.get("event") == "chat"] - assert len(chat_events) >= 1, f"Expected chat events, got: {events}" - - # Verify final event has content - if got_final: - final = [e for e in chat_events if e["payload"].get("state") == "final"] - assert len(final) == 1 - content = final[0]["payload"]["message"] - assert isinstance(content, dict) - - def test_voice_message_visible_in_session_list( - self, warren_url, device_token, auth_headers, - ): - """After voice chat.send, session appears in HTTP session list.""" - with ws_client.connect(_ws_url(warren_url), close_timeout=5) as ws: - _handshake(ws, device_token) - - ws.send(json.dumps({ - "type": "req", - "id": "chat-1", - "method": "chat.send", - "params": { - "sessionKey": "main", - "message": "Session visibility test", - "idempotencyKey": "visibility-1", - }, - })) - - # Wait a moment for session creation - time.sleep(2) - - # Check via HTTP API - resp = httpx.get(f"{warren_url}/sessions", headers=auth_headers) - assert resp.status_code == 200 - sessions = resp.json() - assert len(sessions) >= 1 -``` - -**Step 2: Commit** - -```bash -git add tests/e2e/test_pipeline_e2e.py && git commit -m "test: add full pipeline E2E test" -``` - ---- - -### Task 12: Run full verification - -**Step 1: Lint** - -```bash -cd server && uv run ruff check src/ tests/ -``` - -Expected: Clean. - -**Step 2: Unit tests** - -```bash -cd server && uv run pytest tests/ -v --ignore=tests/e2e -``` - -Expected: All pass. - -**Step 3: Verify no dead references** - -```bash -grep -r "DirectAdapter\|ClaudeRunner\|claude_runner\|claude_bin\|fetchBackend\|backendType\|probe\.js" server/src/ server/tests/ creation/ --include="*.py" --include="*.js" --include="*.html" --include="*.ts" -``` - -Expected: No output (except CLAUDE.md or design docs). - -**Step 4: E2E tests (if docker available)** - -```bash -cd /home/dakota/Work/github/dsr-restyn/warren && docker compose -f docker-compose.yml -f tests/e2e/docker-compose.test.yml up -d --build --wait -cd server && uv run pytest tests/e2e/ -v -docker compose -f docker-compose.yml -f tests/e2e/docker-compose.test.yml down -v -``` - -**Step 5: Final commit and push** - -```bash -git add -A && git commit -m "chore: final cleanup and verification" -git push -``` diff --git a/docs/plans/2026-02-22-voice-channel-design.md b/docs/plans/2026-02-22-voice-channel-design.md deleted file mode 100644 index 9de402d..0000000 --- a/docs/plans/2026-02-22-voice-channel-design.md +++ /dev/null @@ -1,146 +0,0 @@ -# Voice Channel Design - -## Problem - -The R1's `webkitSpeechRecognition` doesn't work in the Android WebView. The R1 -natively supports OpenClaw-compatible voice gateways: hardware PTT button -triggers cloud STT, transcripts arrive over WebSocket as plain text, responses -go back over the same WS for native TTS. No audio processing needed on our end. - -Currently, Warren only has one input path (HTTP from the Creation browser). -Voice adds a second path that shares the same sessions. - -## Architecture - -Warren becomes a protocol translator. Both input paths feed the same sessions -and adapter layer. NanoClaw doesn't know or care how the user is talking. - -``` -R1 Browser (Creation) ──HTTP/SSE──┐ - ├── Warren ──adapter──▶ NanoClaw -R1 PTT (Voice) ───────WebSocket───┘ -``` - -Both paths read from the same event stream via a new pub/sub `SessionBus` that -replaces the current single-consumer `event_queues` dict. - -## Components - -### 1. SessionBus - -**Problem:** `event_queues` is `dict[str, asyncio.Queue]` — one queue per -session, one consumer. Voice needs a second consumer on the same session. - -**Solution:** `SessionBus` — publish/subscribe, multiple queues per session. - -``` -callback ──publish──▶ SessionBus ──▶ SSE subscriber queue - ──▶ Voice WS subscriber queue -``` - -- SSE handler calls `bus.subscribe(session_id)`, gets its own queue -- Voice WS handler does the same -- Callback endpoint calls `bus.publish(session_id, event)` — all subscribers get it -- On disconnect, each handler calls `bus.unsubscribe()` - -**File:** `server/src/warren/bus.py` (~40 lines) - -### 2. Voice WebSocket Endpoint - -**Path:** `/ws/voice` on Warren's existing port (4030). - -Speaks a minimal subset of the OpenClaw protocol: - -| Step | Direction | Message | -|------|-----------|---------| -| 1 | S→C | `connect.challenge` with random nonce | -| 2 | C→S | `connect` with `authToken` (device token) + nonce | -| 3 | S→C | `hello-ok` or `hello-error` | -| 4 | C→S | `chat.send` with `{message}` | -| 5 | S→C | `chat` with `state: "delta"` (streaming) | -| 6 | S→C | `chat` with `state: "final"` (done) | -| - | S→C | `tick` keepalive every 15s | - -**Session binding:** Voice targets the device's most recently active -non-terminated session. If none exists, creates one with the first configured -repo when the first `chat.send` arrives. - -**Event filtering:** Voice only sends `text` and `result` events as TTS. -Tool events and status updates are skipped (the user doesn't need to hear -"Running Bash: git status"). - -**File:** `server/src/warren/voice.py` (~200 lines) - -### 3. Voice Pairing - -Admin endpoint `GET /pair/voice/qr` generates a QR code in the format the R1 -expects for gateway pairing: - -```json -{ - "type": "clawdbot-gateway", - "version": 1, - "ips": [""], - "port": 4030, - "token": "" -} -``` - -Reuses Warren's existing device token system. The token in the QR is a real -device token stored in the DB — same token used by Creation, same auth -middleware. - -**Added to:** `server/src/warren/auth.py` (one function) and `app.py` (one route) - -### 4. Config - -Single new env var: `WARREN_VOICE_ENABLED` (default: `false`). - -When disabled, the WS endpoint rejects connections with 403. When enabled, -accepts and runs the protocol. - -No new ports — voice runs on Warren's existing port 4030. FastAPI handles -WebSocket upgrades natively. - -## What Doesn't Change - -- **NanoClaw:** Zero modifications. Doesn't know voice exists. -- **Creation UI:** Unchanged. Voice messages appear in shared sessions. -- **Adapter layer:** Voice calls the same `adapter.send()` as HTTP. -- **Auth:** Reuses device tokens. No new auth mechanism. -- **Docker networking:** No new ports. WS upgrade on existing port 4030. - -## Event Flow - -``` -Voice (PTT press): - R1 hardware STT ──▶ transcript text - R1 WS client ──────▶ chat.send {message: "check the tests"} - Warren voice.py ───▶ adapter.send(BackendMessage(kind="user_message",...)) - NanoClaw ──────────▶ runs agent, streams callbacks - Warren callback ───▶ bus.publish(session_id, {type:"text", content:"..."}) - bus ───────────────▶ SSE queue (Creation sees it) - ▶ Voice WS queue (voice.py sends chat delta) - R1 native TTS ────▶ speaks the response - -Creation (typed): - R1 browser ────────▶ POST /sessions/{id}/msg - Warren app.py ─────▶ adapter.send(BackendMessage(kind="user_message",...)) - NanoClaw ──────────▶ runs agent, streams callbacks - Warren callback ───▶ bus.publish(session_id, {type:"text", content:"..."}) - bus ───────────────▶ SSE queue (Creation sees it) - ▶ Voice WS queue (if connected, speaks it) -``` - -Both paths are symmetric. A voice message shows up in the Creation chat. -A typed message plays through TTS if the voice WS is connected. - -## Open Questions - -1. **WS path:** The R1 firmware may expect the WebSocket at `/` rather than - `/ws/voice`. Need to test with a real device and adjust if needed. -2. **TLS:** Traefik handles TLS termination for HTTPS. Need to confirm it also - handles `wss://` upgrade correctly (it should by default). -3. **Text accumulation:** Should we accumulate text events into one final TTS - utterance, or stream deltas for real-time speech? Start with accumulating - (simpler, avoids choppy TTS), optimize later if latency is bad. diff --git a/docs/plans/2026-02-22-voice-channel-plan.md b/docs/plans/2026-02-22-voice-channel-plan.md deleted file mode 100644 index f7d8d1a..0000000 --- a/docs/plans/2026-02-22-voice-channel-plan.md +++ /dev/null @@ -1,1164 +0,0 @@ -# Voice Channel Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add a WebSocket voice endpoint to Warren that speaks the R1's OpenClaw protocol, sharing sessions with the existing Creation UI. - -**Architecture:** Warren gets a `SessionBus` (pub/sub replacing single-consumer event queues), a voice WebSocket handler speaking the OpenClaw protocol, and a pairing endpoint. NanoClaw is untouched — Warren translates the voice protocol into the same adapter calls the HTTP path uses. - -**Tech Stack:** Python 3.11+, FastAPI WebSocket, asyncio, pydantic, pytest, `websockets` (test client) - ---- - -### Task 1: SessionBus - -Replace the single-consumer `event_queues: dict[str, asyncio.Queue]` with a -pub/sub bus that supports multiple subscribers per session. - -**Files:** -- Create: `server/src/warren/bus.py` -- Create: `server/tests/test_bus.py` - -**Step 1: Write the failing tests** - -```python -# server/tests/test_bus.py -"""Tests for SessionBus pub/sub.""" - -import asyncio -import pytest -from warren.bus import SessionBus - - -@pytest.fixture -def bus(): - return SessionBus() - - -@pytest.mark.asyncio -async def test_single_subscriber_receives_event(bus): - q = bus.subscribe("s1") - await bus.publish("s1", {"type": "text", "content": "hello"}) - event = q.get_nowait() - assert event == {"type": "text", "content": "hello"} - - -@pytest.mark.asyncio -async def test_multiple_subscribers_receive_same_event(bus): - q1 = bus.subscribe("s1") - q2 = bus.subscribe("s1") - await bus.publish("s1", {"type": "text", "content": "hello"}) - assert q1.get_nowait() == {"type": "text", "content": "hello"} - assert q2.get_nowait() == {"type": "text", "content": "hello"} - - -@pytest.mark.asyncio -async def test_unsubscribe_stops_delivery(bus): - q = bus.subscribe("s1") - bus.unsubscribe("s1", q) - await bus.publish("s1", {"type": "text", "content": "hello"}) - assert q.empty() - - -@pytest.mark.asyncio -async def test_publish_to_nonexistent_session_is_noop(bus): - # Should not raise - await bus.publish("nonexistent", {"type": "text"}) - - -@pytest.mark.asyncio -async def test_different_sessions_are_isolated(bus): - q1 = bus.subscribe("s1") - q2 = bus.subscribe("s2") - await bus.publish("s1", {"type": "text", "content": "for s1"}) - assert not q1.empty() - assert q2.empty() - - -@pytest.mark.asyncio -async def test_has_subscribers(bus): - assert not bus.has_subscribers("s1") - q = bus.subscribe("s1") - assert bus.has_subscribers("s1") - bus.unsubscribe("s1", q) - assert not bus.has_subscribers("s1") - - -@pytest.mark.asyncio -async def test_remove_session(bus): - q = bus.subscribe("s1") - bus.remove_session("s1") - assert not bus.has_subscribers("s1") - # Queue still exists but won't receive new events - await bus.publish("s1", {"type": "text"}) - assert q.empty() -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_bus.py -v` -Expected: FAIL — `ModuleNotFoundError: No module named 'warren.bus'` - -**Step 3: Implement SessionBus** - -```python -# server/src/warren/bus.py -"""Pub/sub event bus for session events. - -Multiple consumers (SSE, voice WS) can subscribe to the same session. -Each subscriber gets its own asyncio.Queue. Publishing broadcasts to all. -""" - -from __future__ import annotations - -import asyncio - - -class SessionBus: - def __init__(self) -> None: - self._subs: dict[str, list[asyncio.Queue]] = {} - - def subscribe(self, session_id: str) -> asyncio.Queue: - """Create and return a new subscriber queue for a session.""" - q: asyncio.Queue = asyncio.Queue() - self._subs.setdefault(session_id, []).append(q) - return q - - def unsubscribe(self, session_id: str, q: asyncio.Queue) -> None: - """Remove a subscriber queue. Safe to call if already removed.""" - subs = self._subs.get(session_id, []) - if q in subs: - subs.remove(q) - if not subs: - self._subs.pop(session_id, None) - - async def publish(self, session_id: str, event: dict) -> None: - """Send an event to all subscribers of a session.""" - for q in self._subs.get(session_id, []): - await q.put(event) - - def has_subscribers(self, session_id: str) -> bool: - """Check if any queues are subscribed to a session.""" - return bool(self._subs.get(session_id)) - - def remove_session(self, session_id: str) -> None: - """Remove all subscribers for a session.""" - self._subs.pop(session_id, None) -``` - -**Step 4: Run tests to verify they pass** - -Run: `cd server && uv run pytest tests/test_bus.py -v` -Expected: 7 passed - -**Step 5: Commit** - -```bash -git add server/src/warren/bus.py server/tests/test_bus.py -git commit -m "feat: add SessionBus pub/sub for multi-consumer event delivery" -``` - ---- - -### Task 2: Migrate app.py from event_queues to SessionBus - -Replace all `event_queues` usage in `app.py` with `SessionBus`. This is a -refactor — behavior is identical from the outside, but now supports multiple -subscribers. - -**Files:** -- Modify: `server/src/warren/app.py` - -**Changes summary:** - -1. **Lifespan** (line 148-149): Replace `app.state.event_queues: dict[str, asyncio.Queue] = {}` - with `app.state.bus = SessionBus()` - -2. **event_pump** (line 85-116): Change `queue = event_queues.get(event.session_id)` / - `await queue.put(event.data)` to `await bus.publish(event.session_id, event.data)`. - The DB persistence logic stays the same but uses `bus.has_subscribers()` instead of - checking dict membership. - -3. **create_session** (line 477): Replace `queues[session_id] = asyncio.Queue()` with - `bus.subscribe(session_id)` — but we don't need the queue here yet. Actually, we just - need the session to exist in the bus. The SSE handler will subscribe when it connects. - Remove the queue creation entirely — SSE `stream_session` already creates/gets a queue. - -4. **send_message** (lines 521-522): Remove `if session_id not in queues: queues[session_id] = asyncio.Queue()`. - The bus handles this automatically when SSE subscribes. - -5. **stream_session** (lines 557-585): Replace queue lookup with `bus.subscribe()`. Add - cleanup via `bus.unsubscribe()` when the SSE generator exits. - -6. **stop_session** (lines 600-603): Replace `queue.put()` with `bus.publish()`. - -7. **delete_session** (lines 617-618): Replace `queues.pop()` with `bus.remove_session()`. - -8. **nanoclaw_callback** (lines 775-819): Replace all `queue = queues.get(session_id)` / - `await queue.put(...)` with `await bus.publish(session_id, ...)`. Remove queue existence - checks — publishing to a session with no subscribers is a safe no-op. - -9. **nanoclaw_typing** (lines 827-831): Same pattern — replace queue.put with bus.publish. - -**Step 1: Apply the refactor** - -In `app.py`, add import: -```python -from warren.bus import SessionBus -``` - -Change `event_pump` signature and body: -```python -async def event_pump(adapter, bus: SessionBus, db): - """Route backend events to session subscribers.""" - async for event in adapter.events(): - await bus.publish(event.session_id, event.data) - if event.kind == "result" and event.data.get("session_id"): - await db.update_claude_session_id(event.session_id, event.data["session_id"]) - - # Save assistant messages to database - event_type = event.data.get("type") - if event_type in ["text", "result", "tool"]: - content = "" - if event_type == "text": - content = event.data.get("content", "") - elif event_type == "result": - content = event.data.get("summary", "") - elif event_type == "tool": - tool = event.data.get("action", event.data.get("tool", "")) - detail = event.data.get( - "file", event.data.get("cmd", event.data.get("summary", "")) - ) - content = f"{tool} {detail}".strip() - - if content: - await db.save_message( - event.session_id, - content, - role="assistant", - event_type=event_type, - event_data=json.dumps(event.data), - ) -``` - -Change lifespan: -```python -app.state.bus = SessionBus() - -pump_task = asyncio.create_task( - event_pump(adapter, app.state.bus, db) -) -``` - -Change `create_session` — remove queue creation line (`queues[session_id] = asyncio.Queue()`). -The bus doesn't need pre-registration. - -Change `send_message` — remove queue existence check block. - -Change `stream_session`: -```python -bus: SessionBus = app.state.bus -queue = bus.subscribe(session_id) - -# Drain stale events (for backward compat with `fresh` param) -if fresh: - while not queue.empty(): - try: - queue.get_nowait() - except asyncio.QueueEmpty: - break - -async def event_generator(): - try: - while True: - try: - event = await asyncio.wait_for(queue.get(), timeout=30.0) - yield { - "event": event.get("type", "message"), - "data": json.dumps(event), - } - if event.get("type") == "status" and event.get("state") == "waiting": - break - except asyncio.TimeoutError: - yield {"event": "ping", "data": ""} - finally: - bus.unsubscribe(session_id, queue) - -return EventSourceResponse(event_generator()) -``` - -Change `stop_session`: -```python -bus: SessionBus = app.state.bus -await bus.publish(session_id, {"type": "status", "state": "waiting"}) -``` - -Change `delete_session`: -```python -bus: SessionBus = app.state.bus -bus.remove_session(session_id) -``` - -Change `nanoclaw_callback`: -```python -bus: SessionBus = request.app.state.bus -# Replace all `queue = queues.get(session_id)` / `await queue.put(event_data)` -# with `await bus.publish(session_id, event_data)` -# Remove if-queue checks — publish to no subscribers is a no-op -``` - -Change `nanoclaw_typing`: -```python -bus: SessionBus = request.app.state.bus -state = "working" if is_typing else "waiting" -await bus.publish(session_id, {"type": "status", "state": state}) -``` - -Change `monitor_agents`: -```python -# This returned list(queues.keys()). With SessionBus, expose _subs keys. -# Add a method to SessionBus: -def active_sessions(self) -> list[str]: - return list(self._subs.keys()) -``` - -Then in the endpoint: -```python -bus: SessionBus = request.app.state.bus -return {"active_sessions": bus.active_sessions()} -``` - -**Step 2: Run existing tests** - -Run: `cd server && uv run pytest tests/ -v` -Expected: All existing tests still pass. - -Run: `cd server && uv run ruff check src/ tests/` -Expected: Clean. - -**Step 3: Commit** - -```bash -git add server/src/warren/app.py server/src/warren/bus.py -git commit -m "refactor: replace event_queues with SessionBus pub/sub" -``` - ---- - -### Task 3: Voice Protocol Module - -Message types and builders for the OpenClaw voice protocol. Pure functions, -no I/O — easy to test. - -**Files:** -- Create: `server/src/warren/voice_protocol.py` -- Create: `server/tests/test_voice_protocol.py` - -**Step 1: Write the failing tests** - -```python -# server/tests/test_voice_protocol.py -"""Tests for OpenClaw voice protocol message builders/parsers.""" - -import json -import pytest -from warren.voice_protocol import ( - build_challenge, - build_hello_ok, - build_hello_error, - build_chat_delta, - build_chat_final, - build_tick, - parse_client_message, -) - - -def test_build_challenge(): - msg = build_challenge("abc123") - assert msg["type"] == "connect.challenge" - assert msg["nonce"] == "abc123" - - -def test_build_hello_ok(): - msg = build_hello_ok("device-1") - assert msg["type"] == "hello-ok" - assert msg["deviceId"] == "device-1" - - -def test_build_hello_error(): - msg = build_hello_error("bad token") - assert msg["type"] == "hello-error" - assert msg["reason"] == "bad token" - - -def test_build_chat_delta(): - msg = build_chat_delta("partial text") - assert msg["type"] == "chat" - assert msg["data"]["state"] == "delta" - assert msg["data"]["text"] == "partial text" - - -def test_build_chat_final(): - msg = build_chat_final("full response") - assert msg["type"] == "chat" - assert msg["data"]["state"] == "final" - assert msg["data"]["text"] == "full response" - - -def test_build_tick(): - msg = build_tick() - assert msg["type"] == "tick" - assert "timestamp" in msg - - -def test_parse_connect_message(): - raw = json.dumps({ - "type": "connect", - "authToken": "tok123", - "nonce": "abc", - "protocol": "1.0", - }) - parsed = parse_client_message(raw) - assert parsed["type"] == "connect" - assert parsed["authToken"] == "tok123" - assert parsed["nonce"] == "abc" - - -def test_parse_chat_send(): - raw = json.dumps({ - "type": "chat.send", - "data": {"message": "hello world"}, - }) - parsed = parse_client_message(raw) - assert parsed["type"] == "chat.send" - assert parsed["data"]["message"] == "hello world" - - -def test_parse_invalid_json(): - with pytest.raises(ValueError, match="Invalid message"): - parse_client_message("not json {{{") - - -def test_parse_missing_type(): - with pytest.raises(ValueError, match="Missing 'type'"): - parse_client_message(json.dumps({"data": "no type"})) -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_voice_protocol.py -v` -Expected: FAIL — `ModuleNotFoundError` - -**Step 3: Implement the protocol module** - -```python -# server/src/warren/voice_protocol.py -"""OpenClaw voice protocol message builders and parsers. - -Implements the minimal subset of the OpenClaw WebSocket protocol needed -for R1 PTT voice input/output. Pure functions, no I/O. - -Protocol flow: - S→C: connect.challenge {nonce} - C→S: connect {authToken, nonce, protocol} - S→C: hello-ok {deviceId} | hello-error {reason} - C→S: chat.send {data: {message}} - S→C: chat {data: {state: "delta"|"final", text}} - S→C: tick {timestamp} -""" - -from __future__ import annotations - -import json -import time - - -def build_challenge(nonce: str) -> dict: - return {"type": "connect.challenge", "nonce": nonce} - - -def build_hello_ok(device_id: str) -> dict: - return {"type": "hello-ok", "deviceId": device_id} - - -def build_hello_error(reason: str) -> dict: - return {"type": "hello-error", "reason": reason} - - -def build_chat_delta(text: str) -> dict: - return {"type": "chat", "data": {"state": "delta", "text": text}} - - -def build_chat_final(text: str) -> dict: - return {"type": "chat", "data": {"state": "final", "text": text}} - - -def build_tick() -> dict: - return {"type": "tick", "timestamp": int(time.time() * 1000)} - - -def parse_client_message(raw: str) -> dict: - """Parse a client WebSocket message. Raises ValueError on bad input.""" - try: - msg = json.loads(raw) - except json.JSONDecodeError as e: - raise ValueError(f"Invalid message: {e}") from e - if "type" not in msg: - raise ValueError("Missing 'type' field in message") - return msg -``` - -**Step 4: Run tests to verify they pass** - -Run: `cd server && uv run pytest tests/test_voice_protocol.py -v` -Expected: 10 passed - -**Step 5: Commit** - -```bash -git add server/src/warren/voice_protocol.py server/tests/test_voice_protocol.py -git commit -m "feat: add OpenClaw voice protocol message builders/parsers" -``` - ---- - -### Task 4: Voice WebSocket Handler - -The core voice endpoint. Handles the OpenClaw handshake, routes `chat.send` -messages to sessions via the adapter, and streams responses back as `chat` -events for TTS. - -**Files:** -- Create: `server/src/warren/voice.py` -- Create: `server/tests/test_voice.py` - -**Step 1: Write the failing tests** - -```python -# server/tests/test_voice.py -"""Tests for voice WebSocket handler.""" - -import asyncio -import json -import pytest -from unittest.mock import AsyncMock, MagicMock, patch -from fastapi.testclient import TestClient -from starlette.testclient import TestClient as StarletteTestClient - -from warren.app import create_app -from warren.config import Settings - - -@pytest.fixture -def settings(tmp_path): - db_path = str(tmp_path / "test.db") - config_path = str(tmp_path / "config.yaml") - (tmp_path / "config.yaml").write_text("repos: {}") - return Settings( - db=db_path, - config=config_path, - backend="direct", - admin_token="admin123", - voice_enabled=True, - ) - - -@pytest.fixture -def app(settings): - return create_app(settings) - - -@pytest.fixture -def client(app): - return TestClient(app) - - -def test_voice_disabled_rejects_connection(tmp_path): - """When voice_enabled=False, WS connections are rejected.""" - db_path = str(tmp_path / "test.db") - config_path = str(tmp_path / "config.yaml") - (tmp_path / "config.yaml").write_text("repos: {}") - s = Settings( - db=db_path, - config=config_path, - backend="direct", - admin_token="admin123", - voice_enabled=False, - ) - app = create_app(s) - client = TestClient(app) - with pytest.raises(Exception): - with client.websocket_connect("/ws/voice"): - pass - - -def test_handshake_sends_challenge(client): - """Server sends connect.challenge immediately on connection.""" - with client.websocket_connect("/ws/voice") as ws: - msg = json.loads(ws.receive_text()) - assert msg["type"] == "connect.challenge" - assert "nonce" in msg - - -def test_handshake_valid_token(client): - """Valid device token completes handshake with hello-ok.""" - # Create a device token via the admin API - resp = client.get("/pair", headers={"x-admin-token": "admin123"}) - pairing_token = resp.json()["pairing_token"] - resp = client.post("/pair/confirm", json={ - "pairing_token": pairing_token, - "device_name": "test-voice", - }) - device_token = resp.json()["device_token"] - - with client.websocket_connect("/ws/voice") as ws: - challenge = json.loads(ws.receive_text()) - nonce = challenge["nonce"] - ws.send_text(json.dumps({ - "type": "connect", - "authToken": device_token, - "nonce": nonce, - "protocol": "1.0", - })) - hello = json.loads(ws.receive_text()) - assert hello["type"] == "hello-ok" - - -def test_handshake_invalid_token(client): - """Invalid token gets hello-error and connection closes.""" - with client.websocket_connect("/ws/voice") as ws: - challenge = json.loads(ws.receive_text()) - ws.send_text(json.dumps({ - "type": "connect", - "authToken": "bad-token", - "nonce": challenge["nonce"], - "protocol": "1.0", - })) - hello = json.loads(ws.receive_text()) - assert hello["type"] == "hello-error" -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd server && uv run pytest tests/test_voice.py -v` -Expected: FAIL — voice_enabled not a valid Setting field / no /ws/voice route - -**Step 3: Add `voice_enabled` to Settings** - -In `server/src/warren/config.py`, add to `Settings`: - -```python -voice_enabled: bool = False -``` - -**Step 4: Implement the voice handler** - -```python -# server/src/warren/voice.py -"""Voice WebSocket handler — OpenClaw protocol for R1 PTT. - -Handles the handshake, routes chat.send messages to the active session -via the adapter, and streams responses back as chat events for TTS. -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import secrets - -from fastapi import WebSocket, WebSocketDisconnect - -from warren.bus import SessionBus -from warren.db import Database -from warren.voice_protocol import ( - build_challenge, - build_chat_delta, - build_chat_final, - build_hello_error, - build_hello_ok, - build_tick, - parse_client_message, -) - -logger = logging.getLogger(__name__) - -# How often to send keepalive ticks (seconds) -TICK_INTERVAL = 15 - - -async def voice_handler( - ws: WebSocket, - bus: SessionBus, - db: Database, - adapter, - config, - voice_enabled: bool, -) -> None: - """Main voice WebSocket handler. Called from the app route.""" - if not voice_enabled: - await ws.close(code=4003, reason="Voice disabled") - return - - await ws.accept() - - # --- Handshake --- - nonce = secrets.token_hex(16) - await ws.send_text(json.dumps(build_challenge(nonce))) - - try: - raw = await asyncio.wait_for(ws.receive_text(), timeout=10.0) - except (asyncio.TimeoutError, WebSocketDisconnect): - return - - try: - msg = parse_client_message(raw) - except ValueError: - await ws.send_text(json.dumps(build_hello_error("invalid message"))) - await ws.close() - return - - if msg.get("type") != "connect": - await ws.send_text(json.dumps(build_hello_error("expected connect"))) - await ws.close() - return - - token = msg.get("authToken", "") - if not await db.validate_device_token(token): - await ws.send_text(json.dumps(build_hello_error("invalid token"))) - await ws.close() - return - - device_id = token[:8] - await ws.send_text(json.dumps(build_hello_ok(device_id))) - logger.info("Voice client authenticated: %s", device_id) - - # --- Connected session --- - # Track the session this voice connection is bound to - active_session_id: str | None = None - event_queue: asyncio.Queue | None = None - - async def find_or_create_session(message: str) -> str: - """Find the most recent active session or create one.""" - nonlocal active_session_id, event_queue - - # If already bound to a session, reuse it - if active_session_id: - session = await db.get_session(active_session_id) - if session and session.get("status") != "terminated": - return active_session_id - - # Find most recent non-terminated session - sessions = await db.list_sessions() - if sessions: - active_session_id = sessions[0]["id"] - else: - # Create a new session with the first configured repo - import uuid - - session_id = uuid.uuid4().hex - repo_name = "" - if config.repos: - repo_name = next(iter(config.repos)) - await db.create_session(session_id, "voice session", repo_name) - active_session_id = session_id - - # Subscribe to events for this session - if event_queue: - bus.unsubscribe(active_session_id, event_queue) - event_queue = bus.subscribe(active_session_id) - return active_session_id - - async def handle_chat_send(msg: dict) -> None: - """Route a voice message to the active session.""" - data = msg.get("data", {}) - message = data.get("message", "") - if not message: - return - - session_id = await find_or_create_session(message) - session = await db.get_session(session_id) - - repo_path = "" - model = "" - append_system_prompt = "" - repo_name = session.get("repo", "") if session else "" - - if repo_name and repo_name in config.repos: - repo_config = config.repos[repo_name] - repo_path = repo_config.path - model = repo_config.model - append_system_prompt = repo_config.append_system_prompt - - await db.touch(session_id) - await db.save_message(session_id, message) - await db.update_status(session_id, "active") - - claude_sid = session.get("claude_session_id") if session else None - - from warren.adapters import BackendMessage - - await adapter.send( - BackendMessage( - kind="user_message" if claude_sid else "new_session", - session_id=session_id, - payload={ - "message": message, - "repo_path": repo_path, - "resume_session_id": claude_sid, - "model": model, - "append_system_prompt": append_system_prompt, - }, - ) - ) - - async def tick_loop(): - """Send keepalive ticks.""" - try: - while True: - await asyncio.sleep(TICK_INTERVAL) - await ws.send_text(json.dumps(build_tick())) - except (WebSocketDisconnect, Exception): - pass - - async def event_relay(): - """Relay session events to voice client as chat messages.""" - nonlocal event_queue - accumulated_text = "" - try: - while True: - if not event_queue: - await asyncio.sleep(0.1) - continue - try: - event = await asyncio.wait_for(event_queue.get(), timeout=1.0) - except asyncio.TimeoutError: - continue - - event_type = event.get("type") - if event_type == "text": - content = event.get("content", "") - if content: - accumulated_text += content - await ws.send_text(json.dumps(build_chat_delta(content))) - elif event_type == "result": - summary = event.get("summary", accumulated_text) - await ws.send_text(json.dumps(build_chat_final(summary or accumulated_text))) - accumulated_text = "" - # Skip tool and status events — not useful for TTS - except (WebSocketDisconnect, Exception): - pass - - # --- Main loop --- - tick_task = asyncio.create_task(tick_loop()) - relay_task = asyncio.create_task(event_relay()) - - try: - while True: - raw = await ws.receive_text() - try: - msg = parse_client_message(raw) - except ValueError: - continue - - if msg["type"] == "chat.send": - await handle_chat_send(msg) - except WebSocketDisconnect: - logger.info("Voice client disconnected: %s", device_id) - finally: - tick_task.cancel() - relay_task.cancel() - if event_queue and active_session_id: - bus.unsubscribe(active_session_id, event_queue) -``` - -**Step 5: Run tests** - -Run: `cd server && uv run pytest tests/test_voice.py -v` -Expected: All pass (note: test_voice_disabled may need adjustment based on how -FastAPI handles WebSocket rejection — may raise a different exception type) - -**Step 6: Run all tests + lint** - -Run: `cd server && uv run pytest tests/ -v && uv run ruff check src/ tests/` -Expected: All pass, clean. - -**Step 7: Commit** - -```bash -git add server/src/warren/voice.py server/tests/test_voice.py server/src/warren/config.py -git commit -m "feat: add voice WebSocket handler with OpenClaw protocol" -``` - ---- - -### Task 5: Voice Pairing + Wire into app.py - -Add the voice pairing QR endpoint and mount the voice WebSocket route. - -**Files:** -- Modify: `server/src/warren/auth.py` — add `generate_voice_qr_png()` -- Modify: `server/src/warren/app.py` — add `/ws/voice` route + `/pair/voice/qr` endpoint - -**Step 1: Add voice QR generator to auth.py** - -```python -# Add to server/src/warren/auth.py - -def generate_voice_qr_png(server_host: str, port: int, device_token: str) -> bytes: - """Generate R1 voice gateway pairing QR code. - - Encodes connection info in the format the R1 expects for - OpenClaw-compatible gateway pairing. - """ - payload = json.dumps( - { - "type": "clawdbot-gateway", - "version": 1, - "ips": [server_host], - "port": port, - "token": device_token, - } - ) - img = qrcode.make(payload) - buf = io.BytesIO() - img.save(buf, format="PNG") - return buf.getvalue() -``` - -**Step 2: Add routes to app.py** - -Add import: -```python -from fastapi import WebSocket -from warren.voice import voice_handler -from warren.auth import generate_voice_qr_png -``` - -Add WebSocket route (inside `create_app`, after the other routes): -```python -@app.websocket("/ws/voice") -async def voice_ws(websocket: WebSocket): - await voice_handler( - ws=websocket, - bus=app.state.bus, - db=app.state.db, - adapter=app.state.adapter, - config=app.state.config, - voice_enabled=settings.voice_enabled, - ) -``` - -Add pairing endpoint (near the other `/pair` routes): -```python -@app.get("/pair/voice/qr", dependencies=[Depends(require_admin)]) -async def pair_voice_qr(request: Request, db: Database = Depends(get_db)): - """Generate QR code for R1 voice gateway pairing.""" - if not settings.voice_enabled: - raise HTTPException(status_code=400, detail="Voice not enabled") - device_token = secrets.token_hex(32) - await db.store_device_token(device_token, "R1 Voice") - server_host = request.base_url.hostname - png = generate_voice_qr_png(server_host, settings.port, device_token) - return Response(content=png, media_type="image/png") -``` - -**Step 3: Run all tests + lint** - -Run: `cd server && uv run pytest tests/ -v && uv run ruff check src/ tests/` -Expected: All pass, clean. - -**Step 4: Commit** - -```bash -git add server/src/warren/auth.py server/src/warren/app.py -git commit -m "feat: add voice pairing QR + mount /ws/voice route" -``` - ---- - -### Task 6: Config + Docker - -Add the `WARREN_VOICE_ENABLED` environment variable to docker-compose and -update CLAUDE.md. - -**Files:** -- Modify: `docker-compose.yml` -- Modify: `CLAUDE.md` - -**Step 1: Add env var to docker-compose.yml** - -In the `warren` service `environment` section, add: -```yaml -- WARREN_VOICE_ENABLED=${WARREN_VOICE_ENABLED:-false} -``` - -**Step 2: Update CLAUDE.md** - -Add to the SSE Event Types section or a new "Voice" section: -```markdown -## Voice Channel - -Warren supports an optional WebSocket voice endpoint for R1 PTT interaction. -Enable with `WARREN_VOICE_ENABLED=true`. Speaks the OpenClaw protocol at -`/ws/voice`. Voice messages route to the same sessions as the Creation UI. - -Pair via `GET /pair/voice/qr` (admin). The QR encodes gateway connection info -for the R1's native voice assistant pairing. -``` - -**Step 3: Commit** - -```bash -git add docker-compose.yml CLAUDE.md -git commit -m "feat: add voice channel config and documentation" -``` - ---- - -### Task 7: Integration Test - -WebSocket integration test using `websockets` library to verify the full -handshake and chat flow. - -**Files:** -- Create: `server/tests/test_voice_integration.py` - -**Step 1: Write integration test** - -```python -# server/tests/test_voice_integration.py -"""Integration test for voice WebSocket — full handshake + chat flow.""" - -import asyncio -import json -import pytest -from fastapi.testclient import TestClient - -from warren.app import create_app -from warren.config import Settings - - -@pytest.fixture -def settings(tmp_path): - db_path = str(tmp_path / "test.db") - config_path = str(tmp_path / "config.yaml") - (tmp_path / "config.yaml").write_text("repos:\n test:\n path: /tmp/test") - return Settings( - db=db_path, - config=config_path, - backend="direct", - admin_token="admin123", - voice_enabled=True, - ) - - -@pytest.fixture -def app(settings): - return create_app(settings) - - -@pytest.fixture -def client(app): - return TestClient(app) - - -def _get_device_token(client) -> str: - """Helper: create a device token via pairing flow.""" - resp = client.get("/pair", headers={"x-admin-token": "admin123"}) - pairing_token = resp.json()["pairing_token"] - resp = client.post("/pair/confirm", json={ - "pairing_token": pairing_token, - "device_name": "test-voice", - }) - return resp.json()["device_token"] - - -def _do_handshake(ws, device_token: str) -> dict: - """Helper: complete the OpenClaw handshake.""" - challenge = json.loads(ws.receive_text()) - assert challenge["type"] == "connect.challenge" - ws.send_text(json.dumps({ - "type": "connect", - "authToken": device_token, - "nonce": challenge["nonce"], - "protocol": "1.0", - })) - hello = json.loads(ws.receive_text()) - assert hello["type"] == "hello-ok" - return hello - - -def test_full_handshake_flow(client): - """Complete handshake: challenge → connect → hello-ok.""" - token = _get_device_token(client) - with client.websocket_connect("/ws/voice") as ws: - _do_handshake(ws, token) - - -def test_chat_send_creates_session(client): - """Sending chat.send when no sessions exist creates one.""" - token = _get_device_token(client) - - # Verify no sessions exist - resp = client.get("/sessions", headers={"Authorization": f"Bearer {token}"}) - assert resp.json() == [] - - with client.websocket_connect("/ws/voice") as ws: - _do_handshake(ws, token) - ws.send_text(json.dumps({ - "type": "chat.send", - "data": {"message": "hello from voice"}, - })) - # Give the handler a moment to process - # (In test, DirectAdapter may not produce output, but the session - # should be created) - - # Session should now exist - resp = client.get("/sessions", headers={"Authorization": f"Bearer {token}"}) - sessions = resp.json() - assert len(sessions) >= 1 - - -def test_voice_pairing_qr(client): - """GET /pair/voice/qr returns a PNG image.""" - resp = client.get("/pair/voice/qr", headers={"x-admin-token": "admin123"}) - assert resp.status_code == 200 - assert resp.headers["content-type"] == "image/png" - # PNG magic bytes - assert resp.content[:4] == b"\x89PNG" -``` - -**Step 2: Run integration tests** - -Run: `cd server && uv run pytest tests/test_voice_integration.py -v` -Expected: All pass. - -**Step 3: Run full test suite + lint** - -Run: `cd server && uv run pytest tests/ -v && uv run ruff check src/ tests/` -Expected: All pass, clean. - -**Step 4: Commit** - -```bash -git add server/tests/test_voice_integration.py -git commit -m "test: add voice WebSocket integration tests" -``` - ---- - -## Verification Checklist - -After all tasks: - -1. `cd server && uv run ruff check src/ tests/` — lint clean -2. `cd server && uv run pytest tests/ -v` — all tests pass -3. `docker compose build warren` — builds clean -4. Deploy with `WARREN_VOICE_ENABLED=true` -5. `GET /pair/voice/qr` — generates QR PNG -6. Scan QR with R1 — confirms pairing -7. Press PTT on R1 — voice transcript appears in Warren session -8. Response plays back via R1 TTS -9. Same session visible in Creation browser diff --git a/docs/superpowers/plans/2026-06-18-remove-nanoclaw.md b/docs/superpowers/plans/2026-06-18-remove-nanoclaw.md deleted file mode 100644 index cfc12a5..0000000 --- a/docs/superpowers/plans/2026-06-18-remove-nanoclaw.md +++ /dev/null @@ -1,413 +0,0 @@ -# Remove NanoClaw 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:** Remove all NanoClaw-specific code, config, tests, the git submodule, and docs from Warren, leaving a clean WebSocket-proxy + Orca backend architecture. - -**Architecture:** Warren keeps two backends selected by `settings.backend`: `websocket` (default, `AgentWebSocketAdapter`) and `orca` (`OrcaAdapter`). The `nanoclaw` branch, file-IPC transport, read-only SQLite monitoring, and HTTP callback/typing endpoints are deleted. Endpoints the R1 frontend still calls (`/monitor/tasks`, pause/resume) are kept as safe no-ops so the UI does not 404. - -**Tech Stack:** Python 3, FastAPI, pytest, ruff, uv. Work happens in the worktree `/home/dakota/Work/github/dsr-restyn/warren` on branch `feature/decouple-and-orca-proxy`. - -## Global Constraints - -- All commands run from `/home/dakota/Work/github/dsr-restyn/warren` (the PR-branch worktree), not the refactor worktree. -- Lint: `cd server && uv run ruff check src/ tests/` must pass before every commit. -- Tests: `cd server && uv run pytest -q` must pass (green) at the end of every task — the suite never goes red between commits. -- Keep `settings.internal_secret` (used by `AgentWebSocketAdapter` auth) and `settings.backend`. Remove only nanoclaw-specific fields. -- Keep the three frontend-facing monitor endpoints reachable (return empty/no-op), since `creation/js/api.js:135-143` calls them. -- One commit per task. Commit message trailer: `Co-Authored-By: Claude Opus 4.8 `. -- Do NOT touch the Orca adapter or the WebSocket adapter logic (out of scope; separate review). - ---- - -### Task 1: Remove NanoClaw internal HTTP endpoints + `require_internal` - -Removes the inbound callback/typing/config endpoints NanoClaw used, and the auth dependency that only guarded them. - -**Files:** -- Modify: `server/src/warren/app.py` — delete `require_internal` (~740-749), `/internal/nanoclaw/config` GET+PUT (~751-781), `/internal/nanoclaw/callback` (~783-839), `/internal/nanoclaw/typing` (~841-850) -- Modify: `server/tests/test_app.py` — delete `class TestInternalConfigEndpoints` (~409-472) -- Delete: `server/tests/test_nanoclaw_callback.py` - -**Interfaces:** -- Consumes: nothing from other tasks. -- Produces: removes `require_internal` symbol — no later task may reference it. - -- [ ] **Step 1: Delete the four nanoclaw internal endpoints and `require_internal` from `app.py`** - -Remove the `async def require_internal(...)` dependency and all four route handlers decorated with `dependencies=[Depends(require_internal)]` (`/internal/nanoclaw/config` GET, `/internal/nanoclaw/config` PUT, `/internal/nanoclaw/callback`, `/internal/nanoclaw/typing`). Leave the `/api/agent/ws` and voice WebSocket routes that follow intact. - -- [ ] **Step 2: Delete the callback test file** - -```bash -git rm server/tests/test_nanoclaw_callback.py -``` - -- [ ] **Step 3: Remove `TestInternalConfigEndpoints` from `test_app.py`** - -Delete the whole class (docstring `"Tests for /internal/nanoclaw/config (requires x-internal-secret)."`). - -- [ ] **Step 4: Verify the import for `secrets` is still needed** - -Run: `cd server && grep -n "secrets\." src/warren/app.py` -Expected: still used by `require_admin`/token compare. If zero hits, remove `import secrets`. - -- [ ] **Step 5: Lint + test** - -Run: `cd server && uv run ruff check src/ tests/ && uv run pytest -q` -Expected: PASS, no collection errors. - -- [ ] **Step 6: Commit** - -```bash -git add -A && git commit -m "refactor: remove nanoclaw internal callback/config/typing endpoints" -``` - ---- - -### Task 2: Remove the NanoClaw adapter and swap test fixtures to WebSocket - -The largest task: deletes the adapter, its `create_adapter` branch and lifespan warning, and repoints every test fixture that instantiated `NanoClawAdapter` to `AgentWebSocketAdapter`. - -**Files:** -- Modify: `server/src/warren/app.py` — remove nanoclaw branch in `create_adapter` (~73-77) and the `if settings.backend == "nanoclaw":` API-key warning block in lifespan (~136-145) -- Delete: `server/src/warren/adapters/nanoclaw.py` -- Modify: `server/tests/test_adapters.py` — delete `class TestNanoClawAdapter` (~34-110) -- Modify: `server/tests/test_app.py`, `test_integration.py`, `test_streaming.py`, `test_voice.py`, `test_voice_integration.py`, `test_voice_pairing.py` — replace `NanoClawAdapter` fixtures with `AgentWebSocketAdapter` - -**Interfaces:** -- Consumes: `AgentWebSocketAdapter(internal_secret="", admin_token="")` from `warren.adapters.websocket` — constructor takes no IPC dir, `start()`/`stop()` are no-ops, `send()` queues an error event if no agent connected (safe for tests that only check session/DB behavior). -- Produces: `create_adapter` now returns only `OrcaAdapter` or `AgentWebSocketAdapter`. - -- [ ] **Step 1: Remove the nanoclaw branch from `create_adapter` in `app.py`** - -Delete the `if settings.backend == "nanoclaw": ...` block so the function starts at `if settings.backend == "orca":` then `else:` → websocket. (Leave the orca/else branches unchanged.) - -- [ ] **Step 2: Remove the nanoclaw API-key warning in lifespan** - -Delete the `if settings.backend == "nanoclaw":` block (lines ~136-145) that warns about `ANTHROPIC_API_KEY`/`CLAUDE_CODE_OAUTH_TOKEN`. - -- [ ] **Step 3: Delete the adapter file** - -```bash -git rm server/src/warren/adapters/nanoclaw.py -``` - -- [ ] **Step 4: Delete `TestNanoClawAdapter` from `test_adapters.py`** - -Remove the entire class; keep the generic `BackendMessage`/`BackendEvent` tests. - -- [ ] **Step 5: Repoint test fixtures** - -In each of `test_app.py`, `test_integration.py`, `test_streaming.py`, `test_voice.py` (two fixtures), `test_voice_integration.py`, `test_voice_pairing.py`, replace the pattern: - -```python -from warren.adapters.nanoclaw import NanoClawAdapter -... -adapter = NanoClawAdapter(ipc_dir=str(tmp_path / "ipc")) -await adapter.start() -``` - -with: - -```python -from warren.adapters.websocket import AgentWebSocketAdapter -... -adapter = AgentWebSocketAdapter() -await adapter.start() -``` - -For `test_voice_integration.py`, keep the `adapter.send = AsyncMock()` line (still valid). Update any docstring/comment mentioning "NanoClaw backend". - -- [ ] **Step 6: Lint + test** - -Run: `cd server && uv run ruff check src/ tests/ && uv run pytest -q` -Expected: PASS. Watch for any test asserting IPC-file side effects — those belonged to the deleted `TestNanoClawAdapter` and should already be gone. - -- [ ] **Step 7: Commit** - -```bash -git add -A && git commit -m "refactor: remove NanoClawAdapter, default to websocket backend in tests" -``` - ---- - -### Task 3: Strip NanoClaw monitoring; keep frontend endpoints as no-ops - -Removes the SQLite reader and the nanoclaw health/task internals, but preserves the three endpoints the R1 UI calls. - -**Files:** -- Modify: `server/src/warren/app.py` — remove nanoclaw block in `/monitor/health` (~632-641); simplify `/monitor/tasks` to return `{"tasks": []}`; make pause/resume no-ops; remove `_nanoclaw_paths` (~56-68) once its last caller is gone -- Delete: `server/src/warren/nanoclaw_db.py` -- Delete: `server/tests/test_nanoclaw_db.py` -- Modify: `server/tests/test_monitor.py` — rename `test_returns_empty_when_no_nanoclaw` → `test_returns_empty_tasks` (~100-113) - -**Interfaces:** -- Consumes: nothing new. -- Produces: `_nanoclaw_paths` no longer exists — no later task may reference it. - -- [ ] **Step 1: Remove the nanoclaw branch in `/monitor/health`** - -Delete the `if settings.backend == "nanoclaw":` block that checks `os.path.exists(db_path)`. Keep the orca and websocket health branches. - -- [ ] **Step 2: Simplify `/monitor/tasks`** - -Replace the body with a backend-agnostic stub (frontend `getTasks()` still calls this): - -```python - @app.get("/monitor/tasks") - async def monitor_tasks( - request: Request, - _token: str = Depends(require_auth), - ): - # Live task monitoring was nanoclaw-specific; no tasks on websocket/orca. - return {"tasks": []} -``` - -- [ ] **Step 3: Make pause/resume no-ops (keep the routes — frontend calls them)** - -```python - @app.post("/monitor/tasks/{task_id}/pause") - async def pause_task(task_id: str, _token: str = Depends(require_auth)): - return {"status": "ignored", "reason": "Task control not supported on this backend"} - - @app.post("/monitor/tasks/{task_id}/resume") - async def resume_task(task_id: str, _token: str = Depends(require_auth)): - return {"status": "ignored", "reason": "Task control not supported on this backend"} -``` - -- [ ] **Step 4: Remove `_nanoclaw_paths` from `app.py`** - -Confirm no remaining callers, then delete the function: - -Run: `cd server && grep -n "_nanoclaw_paths" src/warren/app.py` -Expected: zero hits after Steps 1-3 → delete the `def _nanoclaw_paths(...)` definition. - -- [ ] **Step 5: Delete the SQLite reader and its test** - -```bash -git rm server/src/warren/nanoclaw_db.py server/tests/test_nanoclaw_db.py -``` - -- [ ] **Step 6: Rename the monitor test** - -In `test_monitor.py`, rename `test_returns_empty_when_no_nanoclaw` to `test_returns_empty_tasks` and drop any nanoclaw wording. The assertion `data["tasks"] == []` stays valid. - -- [ ] **Step 7: Lint + test** - -Run: `cd server && uv run ruff check src/ tests/ && uv run pytest -q` -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add -A && git commit -m "refactor: drop nanoclaw monitoring, keep frontend task endpoints as no-ops" -``` - ---- - -### Task 4: Delete the IPC module - -By now `ipc.py` has no importers (adapter gone in Task 2, pause/resume no-ops in Task 3). - -**Files:** -- Delete: `server/src/warren/ipc.py` -- Delete: `server/tests/test_ipc.py` - -- [ ] **Step 1: Confirm no importers remain** - -Run: `cd server && grep -rn "from warren.ipc\|import ipc\|write_ipc" src/ tests/` -Expected: zero hits. If any remain, stop and fix the referencing task first. - -- [ ] **Step 2: Delete the files** - -```bash -git rm server/src/warren/ipc.py server/tests/test_ipc.py -``` - -- [ ] **Step 3: Lint + test** - -Run: `cd server && uv run ruff check src/ tests/ && uv run pytest -q` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add -A && git commit -m "refactor: remove file-IPC module (nanoclaw-only)" -``` - ---- - -### Task 5: Clean up config fields - -Removes nanoclaw-only settings; keeps `internal_secret` and `backend`. - -**Files:** -- Modify: `server/src/warren/config.py` — remove `backend_config` (~50), `nanoclaw_db` (~65), `nanoclaw_ipc_dir` (~66) -- Modify: `server/tests/test_config.py` — delete `test_config_with_backend_config` and `test_config_without_backend_config` (~194-220) - -**Interfaces:** -- Consumes: nothing. -- Produces: `Settings` no longer has `nanoclaw_db`/`nanoclaw_ipc_dir`; `WarrenConfig` no longer has `backend_config`. - -- [ ] **Step 1: Remove the three fields from `config.py`** - -Delete `backend_config: dict[str, dict] = {}` from `WarrenConfig`, and `nanoclaw_db: str = ""` + `nanoclaw_ipc_dir: str = ""` from `Settings`. Keep `internal_secret` and `backend`. - -- [ ] **Step 2: Confirm no remaining references** - -Run: `cd server && grep -rn "backend_config\|nanoclaw_db\|nanoclaw_ipc_dir" src/ tests/` -Expected: zero hits. - -- [ ] **Step 3: Delete the two backend_config tests in `test_config.py`** - -Remove `test_config_with_backend_config` and `test_config_without_backend_config`. - -- [ ] **Step 4: Lint + test** - -Run: `cd server && uv run ruff check src/ tests/ && uv run pytest -q` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add -A && git commit -m "refactor: remove nanoclaw config fields" -``` - ---- - -### Task 6: Clean Docker entrypoint - -**Files:** -- Modify: `server/entrypoint.sh` — remove the nanoclaw IPC dir setup (~8-10) - -- [ ] **Step 1: Remove the nanoclaw lines** - -Delete: -```bash - # Ensure nanoclaw IPC dirs are writable by appuser (nanoclaw runs as root) - mkdir -p /data/nanoclaw/ipc/warren/messages - chown -R appuser:appuser /data/nanoclaw/ipc/warren -``` - -- [ ] **Step 2: Sanity-check the script parses** - -Run: `bash -n server/entrypoint.sh` -Expected: no output (valid syntax). - -- [ ] **Step 3: Commit** - -```bash -git add -A && git commit -m "chore: drop nanoclaw IPC dir setup from entrypoint" -``` - ---- - -### Task 7: Remove the NanoClaw git submodule - -**Files:** -- Delete: `.gitmodules` (only entry is nanoclaw) -- Delete: `nanoclaw/` (submodule working tree) -- Modify: `.git/config` (remove submodule section) - -- [ ] **Step 1: Deinit and remove the submodule** - -```bash -git submodule deinit -f nanoclaw -git rm -f nanoclaw -rm -rf .git/modules/nanoclaw -``` - -- [ ] **Step 2: Remove the now-empty `.gitmodules`** - -Run: `cat .gitmodules` -Expected: empty or only the nanoclaw section. If empty/only-nanoclaw: -```bash -git rm -f .gitmodules -``` - -- [ ] **Step 3: Verify clean status** - -Run: `git status && git config --file .git/config --get-regexp submodule || true` -Expected: no `submodule.nanoclaw` section remains; `nanoclaw/` gone. - -- [ ] **Step 4: Commit** - -```bash -git add -A && git commit -m "chore: remove nanoclaw git submodule" -``` - ---- - -### Task 8: Update docs and skills; delete nanoclaw design docs - -**Files:** -- Modify: `CLAUDE.md` — rewrite intro, architecture list, key abstractions, deploy section, callback-types section to drop nanoclaw -- Modify: `README.md` — description, ASCII diagram, prerequisites, clone command (drop `--recurse-submodules`), `WARREN_NANOCLAW_*` env rows -- Modify: `.claude/skills/setup/SKILL.md` (~51), `.claude/skills/customize/SKILL.md` (~18), `.claude/skills/debug/SKILL.md` (nanoclaw references) -- Delete: `docs/plans/2026-02-20-nanoclaw-integration-design.md`, `-integration-plan.md`, `-service-design.md`, `-service-plan.md` - -- [ ] **Step 1: Delete the four nanoclaw design docs** - -```bash -git rm docs/plans/2026-02-20-nanoclaw-integration-design.md \ - docs/plans/2026-02-20-nanoclaw-integration-plan.md \ - docs/plans/2026-02-20-nanoclaw-service-design.md \ - docs/plans/2026-02-20-nanoclaw-service-plan.md -``` -(Leave other dated historical plans untouched — they are records, not live docs.) - -- [ ] **Step 2: Rewrite `CLAUDE.md`** - -Replace nanoclaw framing with the WebSocket-proxy/Orca model: intro sentence, the `adapters/` file list (drop `nanoclaw.py`, `ipc.py`, `nanoclaw_db.py`), key-abstractions bullets, the "Deploy with NanoClaw" section, and the "NanoClaw Callback Types" section. Reference `PROTOCOL.md` for the WebSocket protocol. - -- [ ] **Step 3: Rewrite `README.md`** - -Update the one-line description, ASCII flow diagram (Warren → WebSocket → Agent), prerequisites, `git clone` (remove `--recurse-submodules`), and remove the `WARREN_NANOCLAW_IPC_DIR` / `WARREN_NANOCLAW_DB` env-var rows. Keep `WARREN_INTERNAL_SECRET` (websocket auth). - -- [ ] **Step 4: Update the three skills** - -In `setup`, `customize`, and `debug` SKILL.md files, replace nanoclaw references with websocket/orca equivalents (backend options, adapter list, debug steps). - -- [ ] **Step 5: Final repo-wide nanoclaw sweep** - -Run: `grep -rin nanoclaw . --exclude-dir=.git` -Expected: only acceptable residue — incidental mentions inside *other* historical `docs/plans/*` files. Zero hits under `server/src`, `server/tests`, `creation/`, `CLAUDE.md`, `README.md`, `.claude/skills/`. If any code/doc-in-scope hit remains, fix it. - -- [ ] **Step 6: Commit** - -```bash -git add -A && git commit -m "docs: remove nanoclaw references from docs and skills" -``` - ---- - -### Task 9: Full verification - -- [ ] **Step 1: Lint** - -Run: `cd server && uv run ruff check src/ tests/` -Expected: PASS. - -- [ ] **Step 2: Full test suite** - -Run: `cd server && uv run pytest -q` -Expected: PASS, no skipped-due-to-import-error, no collection errors. - -- [ ] **Step 3: App boots and routes resolve** - -Run: `cd server && uv run python -c "from warren.app import create_app; app = create_app(); print(sorted({r.path for r in app.routes}))"` -Expected: prints routes; includes `/api/agent/ws`, `/monitor/tasks`; excludes any `/internal/nanoclaw/*`. - -- [ ] **Step 4: Confirm no nanoclaw imports survive** - -Run: `cd server && grep -rin "nanoclaw\|from warren.ipc\|write_ipc" src/ tests/` -Expected: zero hits. - ---- - -## Open questions / out of scope - -1. **Frontend monitor view.** The R1 `creation/` monitor view still renders a task list and (likely) pause/resume controls that are now permanently empty/no-op. Cleaning that UI is a separate frontend task — not included here to keep changes minimal. Flag for follow-up if the empty monitor view is undesirable. -2. **Orca adapter.** Left untouched. The earlier architectural sketch suggested Orca could become an external WS client too, but that is a separate decision beyond "drop nanoclaw." -3. **`PROTOCOL.md` / `examples/agent.py`** already describe the websocket protocol and need no nanoclaw removal. diff --git a/server/Dockerfile b/server/Dockerfile index 64d4f02..b7384b7 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -14,16 +14,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends gosu \ RUN curl -LsSf https://astral.sh/uv/install.sh | sh \ && cp /root/.local/bin/uv /usr/local/bin/uv -# Install Claude Code standalone binary (installs to /root/.local/bin) -# then copy to /usr/local/bin so non-root appuser can run it -RUN curl -fsSL https://claude.ai/install.sh | bash \ - && cp /root/.local/bin/claude /usr/local/bin/claude - -# Install Entire CLI (installs to /root/.local/bin) -# then copy to /usr/local/bin so non-root appuser can run it -RUN curl -fsSL https://entire.io/install.sh | bash \ - && cp /root/.local/bin/entire /usr/local/bin/entire - # Add GitHub SSH host key RUN mkdir -p /root/.ssh && ssh-keyscan github.com >> /root/.ssh/known_hosts @@ -47,4 +37,7 @@ RUN uv sync --no-dev --frozen \ EXPOSE 4030 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:4030/health')" || exit 1 + ENTRYPOINT ["./entrypoint.sh"] diff --git a/server/src/warren/__main__.py b/server/src/warren/__main__.py index 2927910..a9699d6 100644 --- a/server/src/warren/__main__.py +++ b/server/src/warren/__main__.py @@ -6,6 +6,7 @@ import json import logging import os +import secrets import socket import qrcode @@ -35,9 +36,11 @@ def print_startup_qr_codes(settings: Settings) -> str: """Pre-register device token and print ASCII QR codes for the R1.""" local_ip = get_local_ip() server_url = f"http://{local_ip}:{settings.port}" - token = "local-r1-token" + # Generate a fresh, unguessable pairing token each startup. Set + # WARREN_LOCAL_TOKEN to keep a stable token across restarts during dev. + token = os.environ.get("WARREN_LOCAL_TOKEN") or secrets.token_urlsafe(24) - # Pre-register a static token in the database + # Register the pairing token in the database async def save_token(): db = Database(settings.db) await db.initialize() @@ -50,10 +53,10 @@ async def save_token(): logger.warning("Database startup token setup failed: %s", e) print("\n" + "─" * 60) - print("🌅 WARREN RUNTIME SERVER STARTED 🌅") - print(f"Local Host IP: {local_ip}") - print(f"Backend Adapter: {settings.backend.upper()}") - print(f"Database Path: {settings.db}") + print("Warren server started") + print(f" Local host IP: {local_ip}") + print(f" Backend adapter: {settings.backend.upper()}") + print(f" Database path: {settings.db}") print("─" * 60) # 1. R1 Creation / WebView QR Code @@ -68,7 +71,7 @@ async def save_token(): } ) - print("\n📱 SCAN TO PAIR R1 WEBVIEW / CREATION UI 📱") + print("\nPair R1 WebView / Creation UI") print("Scan this QR code with your Rabbit R1 to open the Warren WebView:") qr1 = qrcode.QRCode() qr1.add_data(creation_payload) @@ -87,7 +90,7 @@ async def save_token(): "protocol": "ws", } ) - print("\n🎙️ SCAN TO PAIR R1 VOICE PTT GATEWAY 🎙️") + print("\nPair R1 Voice PTT Gateway") print("Scan this QR code with your R1 to route native voice prompts:") qr2 = qrcode.QRCode() qr2.add_data(voice_payload) diff --git a/server/src/warren/adapters/orca.py b/server/src/warren/adapters/orca.py index e6b5649..51b8a9b 100644 --- a/server/src/warren/adapters/orca.py +++ b/server/src/warren/adapters/orca.py @@ -2,6 +2,10 @@ Enables Warren to act as an R1 interface for Orca-managed worktrees, active terminal sessions, and running agents. + +Experimental: this adapter drives a single local Orca terminal and is not +multi-session. The WebSocket adapter is the supported default backend; use +Orca only for local, single-terminal experimentation. """ from __future__ import annotations diff --git a/server/src/warren/adapters/websocket.py b/server/src/warren/adapters/websocket.py index 21b5da3..cd13d4d 100644 --- a/server/src/warren/adapters/websocket.py +++ b/server/src/warren/adapters/websocket.py @@ -8,6 +8,7 @@ import asyncio import logging +import secrets from collections.abc import AsyncIterator from fastapi import WebSocket @@ -51,15 +52,22 @@ async def handle_websocket(self, websocket: WebSocket) -> None: if auth_header and auth_header.lower().startswith("bearer "): token = auth_header[7:] - # Validate token if configured - is_authenticated = True - if self._internal_secret or self._admin_token: - is_authenticated = False - if token: - if self._internal_secret and token == self._internal_secret: - is_authenticated = True - elif self._admin_token and token == self._admin_token: - is_authenticated = True + # Fail closed: the agent gateway must have a secret configured. Without + # one, any anonymous client could connect and drive sessions. + if not (self._internal_secret or self._admin_token): + logger.error( + "Refusing agent connection: no WARREN_INTERNAL_SECRET (or admin " + "token) is configured. Set WARREN_INTERNAL_SECRET to enable the " + "agent WebSocket gateway." + ) + await websocket.close(code=4001, reason="Server not configured for agent auth") + return + + # Constant-time comparison against each configured secret. + is_authenticated = bool(token) and ( + (self._internal_secret and secrets.compare_digest(token, self._internal_secret)) + or (self._admin_token and secrets.compare_digest(token, self._admin_token)) + ) if not is_authenticated: logger.warning("Unauthenticated agent connection attempt from %s", websocket.client) diff --git a/server/src/warren/app.py b/server/src/warren/app.py index d826f29..eb942ec 100644 --- a/server/src/warren/app.py +++ b/server/src/warren/app.py @@ -627,22 +627,6 @@ async def monitor_agents( bus: SessionBus = request.app.state.bus return {"active_sessions": bus.active_sessions()} - @app.get("/monitor/tasks") - async def monitor_tasks( - request: Request, - _token: str = Depends(require_auth), - ): - # Live task monitoring is not supported on the websocket/orca backends. - return {"tasks": []} - - @app.post("/monitor/tasks/{task_id}/pause") - async def pause_task(task_id: str, _token: str = Depends(require_auth)): - return {"status": "ignored", "reason": "Task control not supported on this backend"} - - @app.post("/monitor/tasks/{task_id}/resume") - async def resume_task(task_id: str, _token: str = Depends(require_auth)): - return {"status": "ignored", "reason": "Task control not supported on this backend"} - # -- Agent WebSocket -- @app.websocket("/api/agent/ws") diff --git a/server/src/warren/config.py b/server/src/warren/config.py index 5340c60..9784f4e 100644 --- a/server/src/warren/config.py +++ b/server/src/warren/config.py @@ -58,7 +58,6 @@ class Settings(BaseSettings): port: int = 4030 db: str = "warren.db" config: str = "config.yaml" - anthropic_api_key: str = "" admin_token: str = "" data_dir: str = "data" voice_enabled: bool = False diff --git a/server/src/warren/voice.py b/server/src/warren/voice.py index b1e2890..4d24c5b 100644 --- a/server/src/warren/voice.py +++ b/server/src/warren/voice.py @@ -8,9 +8,11 @@ from __future__ import annotations import asyncio +import hashlib import json import logging import secrets +import time import uuid from fastapi import WebSocket, WebSocketDisconnect @@ -56,7 +58,7 @@ async def voice_handler( try: data = await asyncio.wait_for(ws.receive(), timeout=10.0) except asyncio.TimeoutError: - logger.warning("Voice handshake: timeout waiting for connect") + logger.debug("Voice handshake: timeout waiting for connect") return if data.get("type") == "websocket.disconnect": @@ -68,7 +70,7 @@ async def voice_handler( if not raw: return - logger.warning("Voice connect: %s", raw[:300]) + logger.debug("Voice connect frame received (%d bytes)", len(raw)) try: msg = parse_frame(raw) @@ -86,15 +88,15 @@ async def voice_handler( token = auth.get("token", "") or auth.get("deviceToken", "") if not await db.validate_device_token(token): - tok_preview = token[:8] if token else "(empty)" - logger.warning("Voice handshake: invalid token: %s", tok_preview) + logger.warning("Voice handshake: invalid token") await ws.send_text(json.dumps(build_hello_error(request_id, "invalid token"))) await ws.close() return - device_id = token[:8] + # Non-reversible correlation id for logs (never log token material). + device_id = hashlib.sha256(token.encode()).hexdigest()[:8] if token else "anon" await ws.send_text(json.dumps(build_hello_ok(request_id, token))) - logger.warning("Voice authenticated: %s", device_id) + logger.info("Voice authenticated: %s", device_id) # --- Session state --- active_session_id: str | None = None @@ -135,7 +137,7 @@ async def handle_chat_send(msg: dict) -> None: if not message: return - logger.warning("Voice chat.send: %s", message[:100]) + logger.debug("Voice chat.send (%d chars)", len(message)) run_id = uuid.uuid4().hex[:12] chat_seq = 0 @@ -185,7 +187,7 @@ async def tick_loop(): while True: await asyncio.sleep(TICK_INTERVAL) await ws.send_text(json.dumps(build_tick())) - except (WebSocketDisconnect, Exception): + except Exception: pass async def event_relay(): @@ -206,7 +208,6 @@ async def event_relay(): # If we have accumulated text and no events for a while, # send final so the R1 can do TTS if accumulated_text and idle_since is not None: - import time elapsed = time.monotonic() - idle_since if elapsed >= IDLE_FINAL_TIMEOUT: chat_seq += 1 @@ -216,21 +217,20 @@ async def event_relay(): "final", accumulated_text, stop_reason="end_turn", ) - logger.warning( - "Voice relay: idle final: %s", - accumulated_text[:80], + logger.debug( + "Voice relay: idle final (%d chars)", + len(accumulated_text), ) await ws.send_text(json.dumps(chat_msg)) accumulated_text = "" idle_since = None continue - import time idle_since = time.monotonic() session_key = client_session_key event_type = event.get("type") - logger.warning("Voice relay event: %s", event_type) + logger.debug("Voice relay event: %s", event_type) if event_type == "text": content = event.get("content", "") @@ -249,17 +249,15 @@ async def event_relay(): session_key, run_id, chat_seq, "final", summary, stop_reason="end_turn", ) - logger.warning( - "Voice relay final: %s", summary[:80], - ) + logger.debug("Voice relay final (%d chars)", len(summary)) await ws.send_text(json.dumps(chat_msg)) accumulated_text = "" idle_since = None except WebSocketDisconnect: - logger.warning("Voice relay: WS disconnected") - except Exception as e: - logger.warning("Voice relay error: %s", e) + logger.debug("Voice relay: WS disconnected") + except Exception: + logger.exception("Voice relay error") # --- Main loop --- tick_task = asyncio.create_task(tick_loop()) @@ -268,7 +266,7 @@ async def event_relay(): try: while True: raw = await ws.receive_text() - logger.warning("Voice recv: %s", raw[:300]) + logger.debug("Voice recv (%d bytes)", len(raw)) try: msg = parse_frame(raw) except ValueError: @@ -278,9 +276,9 @@ async def event_relay(): if msg.get("type") == "req" and method == "chat.send": await handle_chat_send(msg) elif msg.get("type") == "req": - logger.warning("Voice: unhandled method: %s", method) + logger.debug("Voice: unhandled method: %s", method) except WebSocketDisconnect: - logger.warning("Voice client disconnected: %s", device_id) + logger.info("Voice client disconnected: %s", device_id) finally: tick_task.cancel() relay_task.cancel() diff --git a/server/tests/test_app.py b/server/tests/test_app.py index f9a884e..159e5ac 100644 --- a/server/tests/test_app.py +++ b/server/tests/test_app.py @@ -36,7 +36,6 @@ async def client(tmp_path): })) settings = Settings( - anthropic_api_key="sk-test", db=str(tmp_path / "test.db"), config=str(config_file), admin_token="test-admin", diff --git a/server/tests/test_config.py b/server/tests/test_config.py index f78fc46..d31b4d6 100644 --- a/server/tests/test_config.py +++ b/server/tests/test_config.py @@ -7,7 +7,7 @@ def test_default_settings(): """Settings loads with defaults when no env vars set.""" from warren.config import Settings - s = Settings(anthropic_api_key="sk-test") + s = Settings() assert s.host == "0.0.0.0" assert s.port == 4030 assert s.db == "warren.db" diff --git a/server/tests/test_integration.py b/server/tests/test_integration.py index 95ebdbf..867e972 100644 --- a/server/tests/test_integration.py +++ b/server/tests/test_integration.py @@ -25,7 +25,6 @@ async def client(tmp_path): })) settings = Settings( - anthropic_api_key="sk-test", db=str(tmp_path / "test.db"), config=str(config_file), admin_token="test-admin", diff --git a/server/tests/test_monitor.py b/server/tests/test_monitor.py index e048128..f739682 100644 --- a/server/tests/test_monitor.py +++ b/server/tests/test_monitor.py @@ -86,27 +86,3 @@ async def test_returns_active_sessions( data = resp.json() assert "active_sessions" in data assert "session-1" in data["active_sessions"] - - -class TestMonitorTasks: - async def test_requires_auth(self, monitor_app): - async with AsyncClient( - transport=ASGITransport(app=monitor_app), - base_url="http://test", - ) as client: - resp = await client.get("/monitor/tasks") - assert resp.status_code == 401 - - async def test_returns_empty_tasks( - self, monitor_app, auth_headers, - ): - async with AsyncClient( - transport=ASGITransport(app=monitor_app), - base_url="http://test", - ) as client: - resp = await client.get( - "/monitor/tasks", headers=auth_headers, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["tasks"] == [] diff --git a/server/tests/test_streaming.py b/server/tests/test_streaming.py index e3904ea..abf0137 100644 --- a/server/tests/test_streaming.py +++ b/server/tests/test_streaming.py @@ -21,7 +21,6 @@ async def authed_client(tmp_path): })) settings = Settings( - anthropic_api_key="sk-test", db=str(tmp_path / "test.db"), config=str(config_file), admin_token="test-admin", diff --git a/server/tests/test_websocket_adapter.py b/server/tests/test_websocket_adapter.py index 4ba46b3..97b840d 100644 --- a/server/tests/test_websocket_adapter.py +++ b/server/tests/test_websocket_adapter.py @@ -49,6 +49,44 @@ async def ws_endpoint(websocket: WebSocket): websocket.receive_json() assert excinfo.value.code == 4001 +def test_websocket_no_secret_configured_fails_closed(): + """With no secret configured the gateway must reject all connections.""" + from fastapi.websockets import WebSocketDisconnect + + app = FastAPI() + adapter = AgentWebSocketAdapter() # no internal_secret, no admin_token + + @app.websocket("/api/agent/ws") + async def ws_endpoint(websocket: WebSocket): + await adapter.handle_websocket(websocket) + + client = TestClient(app) + + with pytest.raises(WebSocketDisconnect) as excinfo: + with client.websocket_connect("/api/agent/ws?token=anything") as websocket: + websocket.receive_json() + assert excinfo.value.code == 4001 + assert len(adapter._active_connections) == 0 + + +def test_websocket_wrong_token_rejected(): + from fastapi.websockets import WebSocketDisconnect + + app = FastAPI() + adapter = AgentWebSocketAdapter(internal_secret="super-secret") + + @app.websocket("/api/agent/ws") + async def ws_endpoint(websocket: WebSocket): + await adapter.handle_websocket(websocket) + + client = TestClient(app) + + with pytest.raises(WebSocketDisconnect) as excinfo: + with client.websocket_connect("/api/agent/ws?token=wrong") as websocket: + websocket.receive_json() + assert excinfo.value.code == 4001 + + def test_websocket_authenticated_integration(): app = FastAPI() adapter = AgentWebSocketAdapter(internal_secret="super-secret")