|
| 1 | +# desktop bridge contract |
| 2 | + |
| 3 | +A localhost-only browser UI for cecli that talks to `cecli --acp` over stdio. |
| 4 | +The Python bridge and the HTTP/SSE payloads below are the contract; the UI is a |
| 5 | +Lit client built from `ui/` with Vite. |
| 6 | + |
| 7 | +## Runtime layout |
| 8 | + |
| 9 | +``` |
| 10 | +desktop/ |
| 11 | + server.py Python 3.10+ stdlib only; HTTP + SSE <-> ACP stdio bridge |
| 12 | + ui/index.html Vite entry document |
| 13 | + ui/vite.config.js root=ui/, dev proxy for /api, build output ../static |
| 14 | + ui/src/main.js module entry; starts the SSE session |
| 15 | + ui/src/core/ store, reducers, SSE events, snapshot, API, markdown, format |
| 16 | + ui/src/components/ Lit light-DOM components (no Shadow DOM) |
| 17 | + ui/src/styles/ Tailwind/daisyUI entry and component-scoped CSS |
| 18 | + static/index.html generated by `npm run ui:build` |
| 19 | + static/assets/ generated, content-hashed JS and CSS bundles |
| 20 | + tests/ optional test fixtures |
| 21 | + README.md user documentation |
| 22 | +``` |
| 23 | + |
| 24 | +The UI uses Lit, Tailwind CSS v4 and daisyUI. Vite compiles `ui/` into |
| 25 | +`static/`; the bridge serves `static/index.html` at `/` and the hashed bundles |
| 26 | +under `/static/assets/`. Run `npm run ui:build` in `desktop/` after changing |
| 27 | +`ui/`. Python needs no Node at runtime once `static/` has been built, and the |
| 28 | +page makes no external requests: all framework code is bundled. |
| 29 | + |
| 30 | +The server never requires third-party packages. The browser app never loads |
| 31 | +external fonts, scripts, or styles. |
| 32 | + |
| 33 | +## ACP facts the bridge relies on |
| 34 | + |
| 35 | +cecli speaks newline-delimited JSON-RPC 2.0 on stdin/stdout when launched as |
| 36 | +`cecli --acp`. stdout carries protocol only; stderr carries logs. |
| 37 | + |
| 38 | +Handshake order: |
| 39 | + |
| 40 | +1. `initialize` request `{"protocolVersion": 1, "clientCapabilities": {}}` |
| 41 | +2. `session/new` request `{"cwd": "<absolute workspace>", "mcpServers": []}` |
| 42 | +the session/new response is deferred until the coder is ready and contains |
| 43 | +`{"sessionId": "...", "configOptions": [...], "modes": {...}}`. |
| 44 | +3. `session/prompt` request `{"sessionId": "...", "prompt": [{"type": "text", "text": "..."}]}` |
| 45 | + The response `{"stopReason": "end_turn" | "cancelled"}` arrives when the turn ends. |
| 46 | +4. `session/cancel` notification `{"sessionId": "..."}` (no id, no response). |
| 47 | +5. `session/set_config_option` request `{"sessionId": "...", "configId": "mode", "value": "..."}` |
| 48 | + Config options look like `{"id", "name", "category", "type": "select", |
| 49 | + "currentValue", "options": [{"value", "name", "description"} | {"group", "name", "options": [...]}]}`. |
| 50 | + |
| 51 | +Server-to-client requests: `session/request_permission` with |
| 52 | +`{"sessionId", "toolCall": {"toolCallId", "content": [...]}, "options": [{"optionId", "name", "kind"}]}`. |
| 53 | +Kinds are `allow_once`, `allow_always`, `reject_once`, `reject_always`. |
| 54 | +Reply with result `{"outcome": {"outcome": "selected", "optionId": "<id>"}}` |
| 55 | +or `{"outcome": {"outcome": "cancelled"}}`. |
| 56 | + |
| 57 | +Notifications: `session/update` with `{"sessionId", "update": {...}}`. Update kinds: |
| 58 | + |
| 59 | +- `agent_message_chunk` `{content: {type: "text", text}}` — append to assistant text |
| 60 | +- `agent_thought_chunk` `{content: {type: "text", text}}` — append to reasoning text |
| 61 | +- `tool_call` `{toolCallId, title, kind, status, rawInput?, content?, locations?}` |
| 62 | +- `tool_call_update` `{toolCallId, status?, title?, content?, kind?}` |
| 63 | +- `tool_call_content_chunk` `{toolCallId, content: {type: "text", text}}` |
| 64 | +- `plan` `{entries: [{content, priority, status}]}` where status is |
| 65 | + `pending` | `in_progress` | `completed` |
| 66 | +- `usage_update` `{used, size, cost?: {amount, currency}}` |
| 67 | +- `config_option_update` `{configOptions}` |
| 68 | +- `current_mode_update` `{currentModeId}` |
| 69 | +- `state_update` `{state, stopReason?}` where state is `running` | `idle` | `requires_action` |
| 70 | +- others (`session_message`, `subagent_update`, ...) must be ignored gracefully. |
| 71 | + |
| 72 | +Tool `content` entries look like `{"type": "content", "content": {"type": "text", "text": "```\n...\n```"}}`. |
| 73 | +Treat tool content as untrusted text. |
| 74 | + |
| 75 | +## Server CLI |
| 76 | + |
| 77 | +``` |
| 78 | +python3 desktop/server.py [--host 127.0.0.1] [--port 8765] [--workspace DIR] |
| 79 | + [--cecli PATH] [--open] [-- extra cecli args...] |
| 80 | +``` |
| 81 | + |
| 82 | +- `--workspace` defaults to the current directory and is resolved to an absolute path. |
| 83 | +- `--cecli` defaults to `$CECLI_BIN`, then `shutil.which("cecli")`, then `"cecli"`. |
| 84 | +- The child command is `[cecli, "--acp"] + extra_args`, launched with `cwd=workspace`. |
| 85 | +- Bind loopback only by default; warn if `--host` is not a loopback address. |
| 86 | +- Print the listening URL on startup. |
| 87 | +- Handle SIGINT/SIGTERM by terminating the child and exiting cleanly. |
| 88 | + |
| 89 | +## HTTP API |
| 90 | + |
| 91 | +All responses are JSON unless noted. Unknown paths return 404. |
| 92 | + |
| 93 | +| Method | Path | Body | Response | |
| 94 | +|--------|-------------------|----------------------------------------|----------| |
| 95 | +| GET | `/` | - | index.html | |
| 96 | +| GET | `/static/<file>` | - | static file (path traversal denied) | |
| 97 | +| GET | `/api/health` | - | `{"ok": true, "status": "..."}` | |
| 98 | +| GET | `/api/state` | - | snapshot, see below | |
| 99 | +| GET | `/api/events` | - | SSE stream, see below | |
| 100 | +| POST | `/api/prompt` | `{"text": "..."}` | 202 `{"ok": true}`; 409 if busy/not ready; 400 if empty | |
| 101 | +| POST | `/api/cancel` | - | 202 `{"ok": true}` | |
| 102 | +| POST | `/api/new_session`| `{"cwd": "..."}` optional | 202; 409 if busy | |
| 103 | +| POST | `/api/permission` | `{"requestId": 7, "optionId": "y"}` or `{"requestId": 7, "optionId": null}` | 200 `{"ok": true}`; 404 unknown requestId | |
| 104 | +| POST | `/api/config` | `{"configId": "mode", "value": "..."}`| 202 `{"ok": true}` | |
| 105 | +| POST | `/api/restart` | - | 202, kills and respawns the child | |
| 106 | + |
| 107 | +State snapshot: |
| 108 | + |
| 109 | +```json |
| 110 | +{ |
| 111 | + "status": "starting | ready | exited", |
| 112 | + "busy": false, |
| 113 | + "workspace": "/abs/path", |
| 114 | + "sessionId": "...", |
| 115 | + "configOptions": [], |
| 116 | + "modes": {}, |
| 117 | + "agent": {"name": "cecli", "version": "..."}, |
| 118 | + "permissions": [{"requestId": 7, "toolCall": {}, "options": []}], |
| 119 | + "clients": 1 |
| 120 | +} |
| 121 | +``` |
| 122 | + |
| 123 | +## SSE events |
| 124 | + |
| 125 | +`GET /api/events` responds with `Content-Type: text/event-stream`, |
| 126 | +`Cache-Control: no-cache`, `Connection: keep-alive`, `X-Accel-Buffering: no`, |
| 127 | +sends `retry: 2000`, then JSON on unnamed `data:` lines: |
| 128 | + |
| 129 | +``` |
| 130 | +data: {"type": "status", "status": "ready"} |
| 131 | +
|
| 132 | +``` |
| 133 | + |
| 134 | +Event types: |
| 135 | + |
| 136 | +- `status` — `{status: "starting"|"ready"|"exited", detail?, agent?}` |
| 137 | +- `session` — `{sessionId, configOptions, modes}` |
| 138 | +- `update` — `{sessionId, update}` (raw ACP update, pass-through) |
| 139 | +- `permission` — `{requestId, sessionId, toolCall, options}` |
| 140 | +- `permission_resolved` — `{requestId, optionId}` (`optionId` null when dismissed) |
| 141 | +- `prompt_done` — `{stopReason}` |
| 142 | +- `log` — `{message, level}` (child stderr, server notices) |
| 143 | +- `error` — `{message}` |
| 144 | + |
| 145 | +On connect, immediately send the current `status` and, if a session exists, a |
| 146 | +`session` event so a late or reconnecting client can render itself. |
| 147 | +Pending permission requests are replayed the same way, so reloading the page |
| 148 | +during an approval does not strand the turn. |
| 149 | +A heartbeat comment `: ping` goes out every 15 seconds. Remove dead clients. |
| 150 | + |
| 151 | +## Behaviour requirements |
| 152 | + |
| 153 | +- One cecli child and one session per server process. Start the child on |
| 154 | +demand (first API use) or at boot; either is fine, but `/api/state` must |
| 155 | +report the truth. |
| 156 | +- Queue nothing: a prompt while busy is rejected with 409. The UI disables send. |
| 157 | +- When the child exits, resolve open permission requests as cancelled, emit |
| 158 | + `status exited`, and reject further prompts with 409/503. |
| 159 | +- `session/cancel` must be a notification (no id), then the outstanding |
| 160 | + `session/prompt` response reports `cancelled`. |
| 161 | +- Permission request ids are the JSON-RPC ids, which may be numbers or strings. |
| 162 | +- Requests from the child that are not `session/request_permission` must be |
| 163 | + answered with a JSON-RPC error (method not found) rather than hanging. |
| 164 | +- Never block the event loop on subprocess I/O; use asyncio subprocesses. |
| 165 | +- Cap request bodies (e.g. 1 MiB) and return 413 beyond that. |
| 166 | +- Reject `Transfer-Encoding` requests (chunked bodies are unsupported) with 400. |
| 167 | +- Require `Content-Type: application/json` on `/api/*` POSTs and return 415 otherwise. |
| 168 | +- Reject requests whose `Host` is not the bound host or a loopback name, and |
| 169 | + reject cross-origin `Origin` values, both with 403. These stop DNS-rebinding |
| 170 | + reads and plain-text cross-site POSTs against the local bridge. |
| 171 | +- Escape nothing server-side; the UI escapes all text it renders. |
| 172 | + |
| 173 | +## UI requirements |
| 174 | + |
| 175 | +- Clean, light chat layout: soft lavender wash, white surfaces, subtle borders, |
| 176 | + and a centred single-column transcript. No sidebar or always-open settings panel. |
| 177 | +- Header: brand, workspace path, connection status. Before the first turn the |
| 178 | + transcript shows a large empty state with the cecli ASCII wordmark (the same |
| 179 | + art as the cecli.dev hero, without its tagline), a short greeting, and example |
| 180 | + prompt chips. |
| 181 | +- Control strip under the composer: config select chips for the ACP config |
| 182 | + options (mode, model, effort, ...), a compact context-usage badge, and icon |
| 183 | + buttons for logs, new chat, and restart. Logs open as a popover above the |
| 184 | + strip. Responsive down to 720px; usable at 380px, where the strip wraps. |
| 185 | +- Transcript items: user message, assistant message (streamed, small blinking |
| 186 | + cursor while busy), collapsible thoughts, tool cards with status chips and |
| 187 | + expandable output, plan checklist, permission cards with option buttons, |
| 188 | + error notices. Auto-scroll unless the user scrolled up. |
| 189 | +- Composer: Enter sends, Shift+Enter newlines, disabled while busy; send button |
| 190 | + becomes Stop while a turn runs. Show stop/retry affordances. |
| 191 | +- Render a safe subset of markdown: fenced code, inline code, bold, italic, |
| 192 | + headings, links (http/https only), ordered/unordered lists, blockquotes. |
| 193 | + Always escape HTML first. |
| 194 | +- No external network requests from the page at runtime. Lit, daisyUI and |
| 195 | + Tailwind are bundled at build time; there is no CDN. |
| 196 | +- Reconnect to `/api/events` automatically with backoff; resync state on open. |
| 197 | +- Keyboard accessible; permission buttons reachable by tab; `Esc` cancels a turn |
| 198 | + when the composer is not focused. |
0 commit comments