# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is A PySide6 desktop app (default) and a terminal REPL (`--cli`), both Claude Code-style, on top of an external Bolt server's desk API. It uses the same brain, memory, tools and persona as the server's Discord bot and its other desk clients. There is **no import dependency** on the server repo: it's a standalone HTTP client configured through its own `.env`. It was forked from Bolt-Pet with the desktop pet, wake word, STT/TTS, screen context/OCR, notifications, auto-updater and self-restart removed. Pipeline: `typed line → + cwd/OS note → POST /desk/converse_stream (or /desk/converse) → [server relays a command: filectl file op, or a shell command, after y/n approval] → /desk/tool_result → … → reply printed`. ## Commands ```bash run.bat / run-cli.bat / ./run.sh [--cli] # window / terminal; creates .venv; keeps the caller's cwd python -m bolt_console --doctor [--deep] python -m bolt_console -p "message" # one-shot .venv/Scripts/python -m pytest tests # (.venv/bin on Linux/macOS) ``` Runtime dependencies: `requests`, plus `PySide6` for the window only. `bolt_console.gui` is imported lazily, so `--cli` works without Qt. The local venv is Python 3.9, so no `match`, no runtime `X | Y` unions, and no builtin generics outside annotations. ## Architecture (`bolt_console/`) - **`config.py`**: a small hand-written `.env` parser feeding module constants. Nothing else reads `os.environ`. `SESSION_ID` defaults to `pet-console-`. The `pet-` prefix is deliberate: the server advertises `filectl` to pet sessions, and `-console` keeps this a separate conversation from a Bolt-Pet on the same machine. `new_session()` (New chat / `/new`) rotates to `-` and remembers it in `~/.cache/bolt-console/session.json`. `DESK_SESSION_ID` in `.env` overrides the remembered id. Tests must monkeypatch `config.STATE_FILE`. - **`server_client.py`**: the desk protocol. `_relay()` is the shared command → tool_result loop. `converse_stream()` hands each `say` sentence over as it arrives and finishes a tool call through `_relay`. It raises `ServerError` only if nothing was shown yet, which lets the caller fall back to `converse()` invisibly. `Reply.streamed` means "already on screen, don't print again". `run_local_command` runs in `os.getcwd()` with stdin closed and `CREATE_NO_WINDOW`, because under pythonw every cmd.exe would otherwise flash a console. - **`tools.py`**: `Tools`, the `on_command` callable. It routes `filectl` to `file_ops`, answers `petctl`/`dialoguectl` with `PET_UNAVAILABLE` (the server may still emit them), and treats anything else as a shell command. Approval: reads and lists never ask. Shell, write and edit ask via `ui.confirm` unless `mode == "auto"`, and "always" switches the session to auto. A decline returns `DECLINED` to the model rather than raising, so the turn still completes. - **`file_ops.py`**: `filectl {json}` list/read/write/edit. It is a **single-line** JSON wire format because the server's `command` marker only captures to the first newline. `edit` must match exactly once. Parsing (`parse`) is separate from I/O (`execute`). - **`relay_json.py`**: lenient JSON for model-typed payloads. It tries strict parsing first, then named repairs (fences, smart quotes, invalid backslash escapes, stray quotes, trailing commas, Python literals, single quotes), and accepts a repair only if the result parses. Repairs are always reported back to the model, never silent. The backslash repair only touches *invalid* escapes, so `C:\temp` still parses as a tab (that's ambiguous, so it's left alone). - **`ui.py`**: `Terminal` handles plain ANSI rendering, the spinner thread, tool-call/result lines, diffs, `confirm` and `read_message`. All output goes through `_emit` under the same lock the spinner draws under. The spinner redraws by erasing the current line, so output is always written as whole lines (see `assistant_chunk`). Glyphs fall back to ASCII on non-UTF-8 streams. - **`console.py`**: `Session` (one turn, delivered-file download, slash commands, `!` local shell, the loop) and `run()` (argv). Collaborators are injected so tests can drive it. - **`gui/`**: the window. `bridge.Bridge` implements the same surface as `ui.Terminal` (tool_call, tool_result, confirm, assistant_chunk, …) with each call emitting a Qt signal, so the window reuses `console.Session` and `Tools` unchanged. `window.TurnRunner` runs `Session.send` on a QThread. `Bridge.confirm` blocks the worker on an Event until a ToolCard's Allow/Deny calls `answer()`, and `close()` releases it as "no". `window.MainWindow` reacts only to Bridge signals. `widgets.py` holds the transcript pieces (ToolCard, AssistantMessage, Composer, …). `theme.py` holds the colours and the one stylesheet (widgets set object names and dynamic properties for it, then `repolish()`). `icons.py` draws every icon with QPainter, so there are no asset files. `markdown_html` swaps Qt's fixed font for code (Courier New on Windows) with the app's mono font. - **`dictation.py`**: the mic button. It streams 16 kHz int16 to the server's `/desk/stt` websocket (Bolt-Pet's route: the server relays to Deepgram) and buffers every frame. If the live socket never opened or died, `_fallback()` sends the whole buffer once from an empty transcript, so a half-live session's words don't come back twice. A healthy socket is never re-sent, even when it heard nothing, because that would pay twice. Stops on click, on `silence_seconds` after speech, or on `max_seconds`. No Qt and no numpy (the array module does RMS), so it's identical in the Linux single file. The mic and socket are injected. The window turns the callbacks into `DictationSignals`, and the composer is read-only while a recording rewrites it. PySide6 6.10 needs `QTextCursor.MoveOperation.End`, not `cursor.End`. - **Chats**: `server_client.list_chats()`/`create_chat()` wrap `GET`/`POST /desk/chats` (the server mints `chat-…` ids; a chat is only listed once used). Switching is `config.set_session(id)`: a plain reassignment of the `SESSION_ID` every call reads, persisted to `session.json`, and deliberately not locked. The window refuses to switch mid-turn instead. `gui/chat_list_window.py` runs list/create as `ChatTask`s on `QThreadPool.globalInstance()` and hands results back through `ChatSignals`, never calling the network from a slot. The header New chat uses the same pool. `Session.new_conversation()` (terminal `/new`) is blocking and falls back to a local id if the server can't mint one. `chats.py` holds the Qt-free row helpers (the preview drops the `[Bolt Console: …]` and `[on screen right now: …]` notes). - **`file_delivery.py`**: saves files the server queues via `deliver_files`. The name is reduced to its basename and never overwrites an existing file. `Session.check_deliveries` runs after every turn. - **`doctor.py`**: independent checks, each reporting ok/warn/fail plus what to do about it. A check never raises. ## Testing conventions `tests/test_gui.py` sets `QT_QPA_PLATFORM=offscreen` itself and drives real turns through `MainWindow` and its worker thread against the loopback server (`pump_until` processes events). Offscreen Qt has **no font database**, so check the look by rendering with the real platform plus `WA_DontShowOnScreen` and `grab()`, not offscreen. Tests cover logic with injected fakes (`FakeUI`, `FakeShell`, a `Terminal` writing to `StringIO` with a scripted `input_fn`). `tests/test_console.py` is the end-to-end file: a real threaded HTTP server on a loopback port driven through the real `server_client`, `Tools` and `Terminal`. Add a case there when a bug crosses module boundaries. Tests that embed a path in JSON must use `json.dumps(str(path))`, because Windows backslashes are otherwise invalid escapes. ## Fabricated tool results The model sometimes writes tool output as prose instead of emitting a command. The tool marker syntax in `console.annotate()` is load-bearing: vague wording ("use filectl") measurably caused this against the live server, and spelling out `command: ...` fixed it. `console.fabricated()` flags a reply shaped like tool output when `Tools.calls` didn't move, and the next message carries `CORRECTION`. A polluted session keeps copying its own history, so the remedy is New chat. ## Security notes Approved shell commands, writes and edits run as the user with no sandbox. `auto` mode removes the prompt. That is the same "full desktop control" trust model as the server's other desk clients, which is why `ask` is the default here. Keep `DESK_API_KEY` private.