Repository navigation
implementing ROCM and metal support - #3
Open
someRandomDude-a wants to merge 42 commits into
Open
someRandomDude-a wants to merge 42 commits into
someRandomDude-a wants to merge 42 commits into
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bus listeners run on the publisher's thread and take SessionManager._voice_lock, so WakeWord, ActionController, Initiative, ResourceEvents, EmotionProbe, the speech queue and the Discord launcher could each deadlock against it. - Add events/outbox.py: queue events/callbacks under a lock, flush after release, delivered in order by a single drainer. - Move every publish/callback out of component locks; ResourceEvents drops superseded values so a stale snapshot is never published last. - _playback_event ignores non voice/speech events before taking _voice_lock. - EventBus logs listener exceptions instead of swallowing them. - tests/test_lock_discipline.py: static scan for publish-under-lock plus regression tests for the reproduced WakeWord and ActionController deadlocks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t loop - cancel() only cuts a turn still generating or playing: Stop, Sleep and shutdown no longer erase a finished reply; muted or failed TTS keeps the visible text. - Failed, cancelled and shutdown-interrupted turns keep the user's message; errors are archived as errors, not interruptions. - respond() releases the turn lock if superseding the previous reply fails, and validates voice settings before changing turn state. - Unreadable chat history, tool approvals, todo list and probe files are kept as <name>.unreadable-<timestamp> (persistence/preserve.py) or never saved over. Corrupt approvals require approval for every tool; the panel shows the error. - Probe: weights-only restore from probe.pt; weights not loaded are kept on disk. - todo_list: stable IDs from a persisted counter, never reused. - WS bootstrap snapshots, /api/chat, whiteboard capture and Discord attach/detach no longer take session locks on the event loop; close() unsubscribes first. - Log listener/getter failures that were swallowed. Fixes audit findings V001 V012 V015 V018 V067 V068 V114 V115 V160, plus the issues confirmed by the adversarial review of this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scientific_calculator tool passed model-controlled text to eval(); attribute walking (().__class__.__base__.__subclasses__()) reached os.system, so prompt injection could run shell commands. It now evaluates only numbers, arithmetic operators, the listed constants and direct calls to the listed math functions, with bounds on input length, nesting, integer size (checked before powers) and complex results. Errors raise, so the registry reports them as tool errors. Fixes audit finding V003, plus issues found by an adversarial attack review (flat sums rejected as nesting, over-cautious power check, parser MemoryError leaking an empty message, unclear errors). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…confirmations Any local web page or process could drive the backend on 127.0.0.1:8765. Now every HTTP request and WebSocket handshake needs this backend start's bearer token and a loopback Host, and sockets need an app Origin (LocalAPIGuard). - run_server.py binds 127.0.0.1:8765 before the model loads, then mints the token and a separate confirmation key. Dev writes both owner-only under persistent_memories; packaged Electron generates them, passes them to the backend it spawns, and sends the token only after the backend prints RIKO_BACKEND_LISTENING. Both are removed from the backend's environment. - Electron main injects the header for 127.0.0.1:8765 only, so renderer code never sees the token; main's own calls use backendFetch. - Security-sensitive settings (code, models, libraries, programs, file access, network endpoints, judged by key, by each leaf of a section saved at once, and by URL/absolute-path values), turning tool approval off, and Discord access changes return 428 with a single-use challenge. Electron main shows a native dialog (hidden characters escaped) and signs it with the confirmation key, which never goes over the wire. - The Discord worker gets the token from its launcher, exits with its backend, talks only to 127.0.0.1 (localhost URLs are rewritten), and its socket now receives only Discord's own turns. - API docs are served only with RIKO_DEV=1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port the llama.cpp branch's approach (talk to an external llama-server via its base URL and /tokenize) onto main's shared LlamaContextProvider, so any llama.cpp build (CUDA, ROCm/HIP, Metal, Vulkan, CPU) can serve Riko while it keeps the reserved live slot, exact template/tokenizer counts, Responses streaming, tools and cancellation. - LlamaServerProvider waits on /health while the model loads, then requires parallel_slots slots each holding the largest live/initiative/reflection budget, and prints the matching llama-server command if not. - One http.client connection per request; cancelling shuts the socket down, so a request stopped during prefill returns at once and llama-server stops it too. Connects time out after 5 s, and a lost server is checked again on the next request. - runtime.base_url (default http://127.0.0.1:8080) and api_key are validated when the config loads, so Settings rejects a bad address before saving. - Shared slot validation moves into validate_slots; native-only checks stay with InProcessLlamaProvider. The emotion probe remains native-only. - Settings shows the server address, API key and slot fields for this provider with the launch command; README and stale messages point to it. Verified against stock llama-server b11408 with a local Qwen3 4B GGUF on Apple Silicon: warmup, exact counts, streaming timings, tool calls, and cancellation mid-prefill and mid-stream (0.04 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, clean shutdown Audit wave 2, verified on an Apple Silicon Mac with the native Metal bridge. - ASR (V013): one faster-whisper factory, audio/asr.py. auto/default resolve to what CTranslate2 can run here (CPU int8 on macOS); an unsupported pair in an existing config falls back once with a warning, and Settings names the value that cannot run when the pair is edited. Packaged first run writes cpu/int8. - Memory (V007): the semantic index is a numpy float32 matrix instead of FAISS, whose bundled libomp killed the backend beside torch's on macOS (reproduced with one memory and warmup off). Retrieval is identical; the index file is no longer written, and faiss-cpu is gone. - Torch devices (V090): runtime/torch_device.py (auto/cpu/cuda[:n]/mps), Julia maps mps to CPU, retries a failed GPU load on CPU, and no longer leaves torch capped at 4 threads; new memory.device setting. - llama.cpp options (V176, V211): flash_attn auto/on/off (default auto; YAML booleans keep their meaning), n_gpu_layers -2 for all layers and --fit only for -1, row split refused with an actionable message, budgets above the GGUF training context rejected before loading. riko-native returns llama.cpp's own WARN/ERROR lines on failure and offload/flash-attention notes on success; the probe identity records what flash attention resolved. - Shutdown (V162, V094): the active turn is cancelled before uvicorn drains (2 s), Discord stops in parallel with the session close, live native contexts are destroyed at exit or skipped with os._exit so ggml-metal's static destructor cannot abort, managed mode exits itself at 14 s, and Electron escalates SIGTERM to SIGKILL. Ctrl+C no longer prints a traceback. - Environment (V133, V117, V122): install_reqs.sh is strict and picks torch wheels by platform, NVIDIA driver and compute capability (ROCm with rocminfo), preferring the repo .venv; pyproject gains a runtime extra kept equal to requirements-runtime.txt; MCP commands resolve on the child PATH and load failures reach the desktop; the packaged macOS backend gets the Homebrew PATH; only the popup global shortcut is bound by default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- V022: the setup wizard leaves room for the reply (memory budget is n_ctx - max_output), llama_cpp configs without a memory key default the same way (a legacy preset key sets n_ctx only), and Settings checks the budget only when a budget key changes, so existing installs can save. - V025: a second packaged launch stops evaluating main.cjs right after losing the single-instance lock, so it cannot spawn another backend. - V027: Setup refuses a data folder equal to or inside the install folder, which updates and uninstall delete. - V030: quitting with unsaved Settings asks first, before the backend is stopped; a reload asks instead of silently cancelling, and the renderer's guard follows real edits only. - V031: the setup and training-data windows get an opaque dark background. - V020: the tool worker, MCP children (PYTHONIOENCODING) and the frozen backend (PyInstaller UTF-8 mode) use UTF-8 stdio on Windows. - V029: a pending approval takes the overlay's pointer only while hovered. - V028: Linux emulates click-through forwarding by polling the cursor for ignoring windows not covered by another Riko window, and runs under XWayland (deb/AppImage launcher args keep --no-sandbox; direct Wayland launches relaunch once with --ozone-platform=x11). - Whiteboard: a fresh board no longer throws on its first restore. - release.yml installs frontend dependencies before the Python checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s something - V026: electron/package.json keeps only yaml (the one module main-process files require) as a runtime dependency; electron, electron-builder 26.0.12 and the renderer libraries are exact devDependencies, so electron-builder accepts the package and release.yml no longer installs it ad hoc. - V131: Python release dependencies are one hash-locked universal file for Windows, Linux and macOS (tools/release/lock.py via uv; CPU torch), plus a --no-deps lock for EfficientWord-Net; CI installs with --require-hashes. Verified here: the lock installs with pip and the full suite passes on it, and it resolves for x86_64 Linux and Windows. - V032: PyInstaller comes from the lock (6.22.3), and numpy._core._exceptions is a hidden import, so the frozen backend survives numpy 2.4. - V033: the CUDA backend builds with GGML_CUDA_NO_VMM, so riko-native no longer hard-links the NVIDIA driver, and the release check runs in a cleaned environment on GPU-less runners. - V203: --release-check also proves the bundle loads its own libraries and no driver library, imports every lazily imported module and desktop_server, runs a calculator call through the real --tool-worker and dry-runs the Discord worker; without a library it checks the dev tree (and a test does). - A tool worker that prints nothing now reports its exit code and logs stderr. - opencv-python is dropped: nothing imports cv2. - release.yml runs every step under bash, so a failing command fails its step on Windows too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, macOS DMG - V023/V207: electron/native_backends.json names every bundled backend (cuda, vulkan, metal), the platforms that ship it, per-OS library names, shared-library patterns, rpaths and CMake switches. config.py (bundled:<backend>), release.cjs (hardware detection runs only the current OS's probes, none on macOS), first_setup.jsx and build.py read it, and naming is tested with explicit per-platform tables on every host. - V034: build.py builds the manifest's backends for the host OS: Metal on macOS with @loader_path rpaths and .dylib collection (versioned links kept), CUDA and Vulkan on Windows and Linux. It also puts Code/ on PyInstaller's path: --collect-submodules process silently froze none of Riko's dynamically imported modules, so the frozen tool worker never worked on any OS. - V057: an arm64 macOS 14+ DMG with hardened runtime and the three entitlements it needs (allow-jit, audio-input, disable-library-validation, each proven necessary), a microphone usage string, an icon rendered from assets/logo.svg, and ad-hoc signing unless Developer ID secrets are set (retried like electron-builder's own signing). The packaged app asks for the microphone before starting the backend and says once when access is off. release.yml gains a macos-15 leg and repeats the release check inside the signed app (build.py --check-app). - Electron's runAsNode, NODE_OPTIONS and --inspect fuses are off, so other processes cannot relaunch Riko as Node to use its microphone permission. - Checksums cover only shipped folders; macOS notes that signing changes them. Built end to end on an M5 Pro: Metal bundle, frozen backend passing --release-check on its own and inside the signed app, and a 547 MB DMG that mounts and verifies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…native smoke - V035: .github/workflows/ci.yml runs the full Python suite on ubuntu-22.04, windows-2022 and macos-15 with the hash-locked release dependencies and a per-test timeout (pytest-timeout, now in the lock), plus an Electron job (npm ci, node --check, npm test, npm run build). A push and its pull request share one run. release.yml tests and builds the frontend before the long native build and has job and step timeouts. - V200: the default suite passes on a fresh clone: test_server_logging tests configure_logging and run_server.main (bind-first, secrets, Ctrl+C, port in use) without importing desktop_server or binding a port; the private-config test skips when the file is absent; the permission-denied store test simulates a denied read instead of chmod(0), which Windows ignores. - V199: .github/workflows/native-smoke.yml builds the patched bridge at the pin with Metal on macos-15 (build.py --smoke-bundle) and runs a pinned Apache-2.0 Qwen2.5-0.5B GGUF through InProcessLlamaProvider: warmup, GPU offload, bundle self-containment, slots, token counts, streaming, a background lane, cancellation, clean close, and emotion-probe samples on the live slot only (tests/test_native_smoke.py, marker `native`). It runs when native, inference, probe, configuration or release code changes, and weekly. - A mocked test keeps the probe's FEATURE_VERSION equal to the native patch. Verified here: 712 passed, 0 failed; npm test 175; the native tests pass against the repo's Metal bridge and a freshly built smoke bundle; the suite also passes in a Linux container as a non-root user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Audit waves 1–3: deadlocks, data safety, API lockdown, Metal support, releases and CI
Prompts stay inside the context, their prefix stays cacheable, and stores stop growing with the conversation. - Memory captures (V002): each capture stores a bounded formation_context (the newest 12 history messages, strings clipped to 1000 characters, lists to 20 items) instead of the whole history, so the store no longer grows quadratically. Retrieval and index rebuilds no longer copy them. - task_get (V016): returns the newest change summaries (history_limit, default 5, max 20) with a history_omitted count. TaskMCP server 1.1. - Remote token estimates (V008): OpenAI-compatible providers estimate with a pre-tokenizer approximation calibrated against the Qwen3/3.5, Llama 3, Gemma 3, Mistral Nemo and DeepSeek V3 tokenizers, instead of UTF-8 bytes (3-4x over). High-entropy runs (base64, JWTs, API keys), all-caps text and random letters are charged per character so they never under-count. - Strict chat templates (V061): OpenAI-compatible providers fold later system messages into the next input, as the native path does. Turn recall always folds into the input, so the system prompt stays byte-identical even when packing drops every past turn. - Runtime observations (V056): the tool loop adds one only when the runtime state (not just its timestamp) changed, and keeps the ones it sent, so each tool-loop request extends the previous one (KV cache, Responses ids). - Packing (V009): a per-ChatService anchor keeps the retained history start between turns, so a steady turn costs one count and the prefix stays fixed. Overflow trims history to three quarters of its room and re-expands below half; a cold start keeps a history that already fits. - Oversized tool output (V058): as a last resort every current tool result is cut to the largest common size that fits, head and tail kept, with a marker, instead of failing the turn. Tests: 750 passed (full suite), Electron 175, native Metal smoke with Qwen2.5-0.5B. The adversarial review's four findings are fixed with regression tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spoken input is never lost or answered twice, remote turns stay isolated from the room's microphone, Stop takes effect at once, and a multi-word companion name no longer breaks startup. - Voice-turn contract (V010, V069): voice_transcript returns what to do with each final transcript. 'fresh' starts a new turn (no spoken reply in flight, a stale anchor, or a reply already finished or stopped); 'reply' redoes the reply the words cut, after merging them into its question or inserting them as speaking over; 'preserved' keeps tolerated speaking over. A continuation no longer cancels a reply and leaves Riko silent, a late follow-up is no longer merged into an answered question, and a redo that another turn overtook asks the words as a new turn instead of recording them twice. Speech that crosses the threshold after Stop cuts nothing. - Remote turns (V011): speak=False turns (Discord) never engage the wake window or anchor local speech, so the room cannot cancel them. - Waiting dispatch (V161): a finished utterance waits for the running turn (respond(wait=...)) instead of being refused and lost; input is recorded only once the turn lock is held; a turn's history base is measured as it takes over. - Stop (V065, V109): reasoning deltas raise TurnCancelled; OpenAI- compatible streams are cancelled per request (cancellable_stream shuts the socket down, so a live Stop never touches initiative or reflection); tool calls wait in slices, and Stop kills that call's worker or sends the MCP server notifications/cancelled. /api/chat answers 409 while another turn runs, and the chat box keeps the text. /api/voice/interjection redoes a reply it cut. - Wake name (V014): the wake phrase is voice.wake_word, else the first word of the companion name; an unusable one disables only wake detection. Settings validates the effective phrase and quotes it; the wizard writes a valid one; a SessionManager construction failure takes the setup-mode fallback; `animation: false` turns animation off. Tests: 804 passed (full suite), Electron 177, the threaded suites 10/10 runs, native Metal smoke. The 12 new cancellation tests fail on the old code. The adversarial review's confirmed finding (a stale redo recorded the words twice) and four low findings are fixed with regression tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A network blip no longer costs a Discord reply or the bot's turn queue,
saves survive Windows sharing violations and power loss, and the memory
store reads past and future schemas instead of breaking every turn.
- Discord (V024): a failed streamed send or edit costs only that chunk
and is retried on the next flush with the same per-chunk nonce, so
Discord never posts it twice (a retry that gets back an earlier copy
edits it to the current text). finish() is idempotent and always
attempts the final text. process_queue guards every cleanup step, and a
done-callback restarts the turn queue if it ever dies while not closing.
- Atomic writes (V080): persistence/atomic.py atomic_write writes a
unique temporary file beside the target, syncs it (F_FULLFSYNC on
macOS), retries os.replace briefly on PermissionError (antivirus,
indexers), fsyncs the directory, and sweeps temporaries a killed process
left. Chat history, the memory store and the JSON stores use it; a
failed history save after a reply is logged and the next save catches
up. Avatar geometry is written outside DesktopState's lock.
- Memory store schema (V084, V182): every field is type-checked; a wrong
value is read losslessly or reset to its default, logged, and the file
as it was is kept once, by copy only, before the next save (a move on a
full disk could leave no store). Unknown keys are kept and written back.
The store is written in the layout it was read in, so main and older
branches still read it; {"version", "records"} and STORE_MIGRATIONS are
ready for a real version bump, and a migrated store is kept once too.
A newer version or structural damage fails closed. retrieve tolerates
bad timestamps.
- Backups (*.unreadable-*) and write temporaries under
persistent_memories/ are gitignored, so private memories and chat
history cannot be committed by accident.
Tests: 841 passed (full suite), Electron 177, the async and store suites
10/10 runs, native Metal smoke. The user's real memory store loads with
no repairs and re-saves identically (checked on a copy). The adversarial
review's two confirmed findings (a duplicate chunk after an ambiguous
send failure; a full disk moving the only store aside) and its four low
findings are fixed with regression tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Audit wave 4: conversation and turn correctness
…nts build on The app core had one import cycle through nine packages, and importing any leaf module loaded about 38 of them. Neither is true any more, and two ratchet tests keep it that way while later segments shrink the coupling. - Lazy facade (V186): app_core/__init__.py resolves its 16 public names on first use (PEP 562), with the same objects as before. `import process.app_core` loads 1 app_core module instead of 38; the task store loads 3 instead of 39. - kernel/: the leaf utilities that formed the cycle (workers, lifecycle, messages, streaming, output_filter, metrics, torch_device, background_budget, and cancellation with a Deferred base for TurnCancelled and BackgroundPreempted) import nothing else from app_core. read_gguf moved to inference/gguf.py. Every old path is a two-line re-export shim for one release, checked for identity. - Cycle breakers: ReflectionDeferred subclasses kernel BackgroundPreempted; ToolRegistry takes an injected activity observer (the factory passes DesktopState) instead of importing it; run_server dispatches --tool-worker, --discord-worker and --release-check before uvicorn or the configuration load, and built-in tools load inside iter_tools(), so a tool call no longer imports uvicorn or pypdf. - Ratchets: tests/test_package_boundaries.py pins the 43 cross-package import edges (load-time or lazy), forbids cycles even through function-level imports, keeps kernel free of outgoing edges, and rejects any route to the facade from a subpackage. tests/test_private_access.py pins the 58 cross-object private-attribute accesses, direct or through getattr/setattr/hasattr/delattr. Both fail on growth and on stale entries, so later segments shrink them in the same change. - Release: the release check imports every kernel module and resolves every facade name; a probe proves neither importing run_server nor its worker dispatch loads uvicorn or the configuration. Tests: 852 passed (full suite), Electron 177, run_server --release-check on the dev tree, native Metal smoke, and a live managed-mode backend in an isolated data root (chat over HTTP, then shutdown mid-reply in 0.6 s). The adversarial review's findings, all on the ratchets' reach (getattr access, own-name masking, facade imports via `process`, the worker-dispatch probe), are fixed with self-tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…icit wiring Other modules no longer reach into SessionManager's private fields, chat history has a single writer, and collaborators are passed in instead of patched on. - Turns (V183, V194): kernel TurnBusy (a RuntimeError with the same message) and a TurnGate that owns the turn lock (begin with Wave 4's wait mode, try_begin for initiatives). /api/chat, /api/discord/chat and /ws/chat map TurnBusy to 409 by type, not by message text. - Status (V183): one locked RuntimeStatus snapshot with named predicates (replying, local_reply, is_quiet, idle_for_background, model_state) replaces six hand-written busy checks that disagreed. Animation, wake feedback, voice anchors, initiatives, expression idling, the calibration guard and memory foregrounding all read it. - Public surface: is_open, active_turn(), cancel_turn(turn_id), set_user_speaking(), transcribe() and history_snapshot(). AsrService owns the one Whisper model that voice input, warmup and Discord share. The Discord socket's event filter lives in the Discord router. The private- access ratchet shrinks by 27 entries. - History (V299): ConversationHistory is the only writer of chat history and chat_history.json: snapshots are copies, every change is one rewrite under a leaf lock, saves are atomic, a failed save is retried by the next change, and unreadable files stay fail-closed. chat.history is a read-only copy. - Injection (V184): ChatDeps (with a real close() owning the factory's ExitStack), a per-turn TurnContext instead of patched attributes, declared RuntimeConfig budget fields, DesktopServices injected into the desktop tools, EmotionWorker.playback_active(), and __slots__ on ChatService, DesktopState and RuntimeConfig so undeclared attributes fail at once. - Found by the live smoke: a reply with no visible text keeps ChatService's '...' placeholder in history (only there, not in what voice anchors read), and template_messages joins consecutive user (or plain assistant) messages, because history keeps the user's message of a stopped, failed or unheard turn and Gemma/Mistral templates require alternating roles. - From the review: a spoken initiative validates its voice and speech settings before committing its message, so a bad value no longer leaves a message nobody saw in history on every retry. Tests: 881 passed (full suite), Electron 177, threaded suites 5/5 runs, release check, native Metal smoke, and a live managed-mode backend in an isolated data root (chat over HTTP, history saved through the single writer, shutdown mid-reply in 0.6 s). The adversarial review confirmed no medium or high findings; its low findings are fixed with tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Providers and tools now have one declared shape each, so callers stop
probing for members, an emotion-probe failure no longer takes the model
down, and an MCP server can no longer shadow a built-in tool or inherit
its approval.
- Providers (V185): an InferenceProvider protocol and a BaseProvider with
defaults for every member except generate, ProviderCapabilities (slots,
exact tokens, latent probe), role lanes, and optional ProbeHost,
CaptureHook and GenerationObserver protocols. OpenAI, llama_server and
the native provider all subclass it; expression_idle is a declared
hook; a test forbids hasattr/getattr provider probing. The slot
scheduler allows at most one initiative lease (a waiting one no longer
holds reflections back), and cancel_live stops only a live lease.
- Emotion probe (V187): the model loads first and the probe starts in a
separate try, so a stale library, missing torch or Julia, or an
unreadable file leaves chat up, reports probe_error in
/api/neural/status, and slows the unused capture to its sparsest. Probe
parsing moved out of the generation loop into emotion/probe_hook.py
observers (a failing observer never fails a request; inference no
longer imports emotion). JuliaEmotionEngine has a public API; the probe
identity key is pinned so trained probes still match. Example replay
runs in EmotionProbe.replay on a 'probe_replay' role that gives slot 0
back to any live request. Fingerprints are cached per file state.
- Tools (V193, V152, V118): one Tool protocol and one
ToolRegistry.register(tool, *, source) for built-ins, desktop tools,
TaskMCP, mcp.json servers and the session's tools. A clash between
Riko's own tools raises; an external tool that clashes or has an
invalid name becomes <server>__<name>, keeping its own name for
tools/call. Isolation is an explicit attribute; tool-specific choice
rules live in the tools; initiative offers only read_only tools.
registry.py is split into tool, schema, mcp, isolation and policy
modules. The model-visible tool set is unchanged.
- Approvals: rules are keyed by (source, name). Legacy {name: bool} files
load unchanged. Saves write version 2 plus a top-level {name: bool}
mirror that asks first wherever any registered source would ask, so a
build from before sources fails closed on a downgrade.
Tests: 917 passed (full suite), Electron 177, threaded suites 5/5 runs,
release check, native Metal smoke (3, including a probe that cannot start
leaving the real model answering), and a live managed-mode backend in an
isolated data root. Review fixes: a gesture cancel with an empty name was
rejected by choice repair; a failed probe kept capturing densely; the
mirror ignored default-governed server tools.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Voice, speech and GPT-SoVITS values are checked once when the config loads instead of at every turn or frame, and every setting is declared once, by the feature that owns it. - Typed sections (V188): VoiceConfig, SpeechConfig and SovitsConfig, checked in load_config through shared kernel/validation helpers, with the audio geometry constants beside them. Session, voice input, wake word, speech queue and TTS read the typed fields, so a bad value fails at load naming the key instead of failing every reply. Unknown keys in any section are reported (log and Settings warnings), never a startup failure. The KV pool is sized only for llama_cpp, so an OpenAI- compatible provider with llama-only keys no longer crashes load_config. Settings explains that an enrolled wake word's own threshold overrides voice.wake_threshold. - Schema registry (V189, Python side): kernel/schema.py (Setting, Section, Rule, Registry) where each feature declares its own sections; configuration imports only kernel and persistence (six package edges removed). load_config runs every registered check; SettingsStore's fields and values are generated from the registry (the hand-kept ENUMS/RANGES/HELP/LABELS tables are gone); /api/settings adds visible_when, the cross-field rules, restart scopes and restart_pending; save_settings calls registered live-apply hooks. Adding a setting is one Setting entry. The Electron Settings UI is unchanged and keeps working; it adopts the new keys in segment 6. - From the review: a section load_config cannot read no longer kills the backend before the setup fallback (load_config(recover='setup') loads it with its defaults and the lifespan starts in setup mode, Settings' snapshot opens it, validation stays strict); voice Settings ranges are exactly what the loader accepts, so a working value never blocks Save; a fractional stored value keeps a field non-integer; a null section can be edited; Avatar model stays above Animation engine. Tests: 972 passed (full suite), Electron 177, release check, native Metal smoke, a live managed-mode backend, and the user's real character_config.yaml (checked on a copy: same values, clean validation, Settings payload unchanged apart from additive keys). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backend's file locations, desktop state and HTTP surface each have one owner: stores get their paths from DataPaths, DesktopState is a facade over components, and create_app builds the server from routers with no work at import. No data moves; every route, payload and event is unchanged. - Data paths (V106): configuration/paths.py builds a frozen DataPaths once in load_config (every store, honouring today's per-file overrides with ~ and keeping data an earlier release wrote elsewhere where it is), and kernel/code_paths.py finds the checkout or frozen bundle. Every store, the Discord launcher and worker, the task MCP server (now defaulting to the backend's store), the tool worker and todo_list (no longer cwd-relative) use them; run_server exports an absolute RIKO_DATA_DIR. Tests pin every path to today's in the dev and packaged layouts. - Desktop state (V191): WhiteboardModel, SurfaceGeometry, EffectsModel, playback flags and presence, and ActivityLog, each with its own lock and publishing through a shared DesktopEvents hub outside it, behind a thin facade whose state.snapshot is unchanged. Public methods replace the reaches into its lock and _emit (private-access ratchet -5). - App factory (V095): create_app(config, ...) with no import-time work, a Backend on app.state instead of module globals, 15 domain routers plus the Discord router under app_core/http/, run_server building the app before binding and minting secrets after, ResourceEvents computing on a worker so publishers never run route code, and desktop tools registered by the factory (the tools -> desktop edge is gone). desktop_server.py stays a thin compatibility shim for one release. - From the review: a Discord worker started by hand keeps using the .env and channel preferences it used before when the config's folder has none; a whiteboard note with a lone surrogate no longer stops every later save (ASCII escapes, failures reported in the snapshot); an event ordering test no longer depends on thread timing. Tests: 998 passed (full suite), Electron 177, threaded and socket suites 5/5 runs, release check, native Metal smoke, a live managed-mode backend, and a real Electron run against an isolated backend (0 refused requests, no app errors). Real data paths checked read-only: every existing store resolves to its current file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…riven Settings Electron gets its backend address from one place, its main process is small modules with behavioural tests instead of source regexes, and the Settings page, first run and launch reads follow the backend's schema. - One backend origin (V192): electron/backend_origin.cjs defines the default (http://127.0.0.1:8765, RIKO_BACKEND_URL in dev); the packaged app runs its backend on a free port (RIKO_PORT, announced as 'RIKO_BACKEND_LISTENING port=<n>'), so a dev backend on 8765 is never mistaken for it. Every window gets the origin through preload (rikoConfig.backend); the token goes only to that origin; api.mjs request() is the one renderer fetch wrapper and survives non-JSON errors. The Discord worker follows RIKO_PORT. A guard test fails on any other copy of the address. - Main process (V195, V208): main.cjs is wiring for backend_supervisor, window_manager, window_gesture, window_layout (one compact-size rule), window_platform, overlay_input and an IPC router whose route table checks the sender for every channel (the displays channel had no check). Behavioural tests with fakes replace the source regexes, and StreamChat's events go through a tested reducer. - Settings (V189, Electron half): the page shows fields by the backend's visible_when, checks the backend's cross-field rules (a shared contract file keeps JS and Python agreeing), and reports which restart each saved change awaits. The packaged first run asks 'riko-backend --setup-config' for its configuration instead of authoring one, and launch-time reads of desktop.debug, shortcuts and the name follow YAML 1.1 like the backend. - From the review: the main-process test fixtures used the host platform, so on the Linux CI runner a pointer-forwarder timer kept the test process alive and a preload test threw; the fixtures now default to an explicit platform and the forwarder's poll never keeps a process alive. A field the draft's provider does not use no longer blocks Save, in Settings or in --validate-config. A logging test no longer depends on running after others. Tests: 1032 passed (full Python suite), Electron 251 (and the suite run as Linux no longer hangs), npm run build, node --check on every .cjs, release check, native Metal smoke, a live managed-mode backend, and a real Electron run against an isolated backend (0 refused requests, no origin or URL errors). The packaged first run's --setup-config path is tested with a fake backend only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by PR #3's CI. - Windows upper-cases environment names, so the launcher's Discord_backend_url arrived as DISCORD_BACKEND_URL and DiscordAccess, which matched exact names, let the .env's address win: a managed Discord worker on Windows would ignore its parent backend's port. BotSettings.overrides matches the environment to the keys from_env reads without case, for the settings and their cache key. The test now also sets the upper-case form. - The avatar-assets test relied on the filesystem's timestamp resolution for "the source is newer"; it now sets the source's modification time explicitly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Audit wave 5: modularity, seams and contracts
…bundle The bridge finds its GPU backends wherever it is installed, x86 bundles run on CPUs without AVX2, staging stores each library once, and AMD GPUs on Linux get an opt-in HIP bundle that setup offers only where it can run. - Backend loading (V318): riko-native loads ggml backends from its own folder before llama_backend_init (dladdr / GetModuleHandleExW; on Windows it pre-loads each ggml-*.dll with the DLL-directory search so cudart and cuBLAS resolve beside it) and notes when no GPU device registered. The patch adds an INSTALL_RPATH and stops a static Linux configure that would fail to link without position-independent code. - CPU variants (V281): cuda and vulkan build GGML_BACKEND_DL with GGML_CPU_ALL_VARIANTS, decided per backend in the manifest; Metal stays linked. The release check proves a CPU module loads from the bundle. - Staging (V130): build.py refuses stale outputs, moves PyInstaller's output instead of copying it, keeps versioned library links once, and checks every bundle for its GPU and CPU modules. - ROCm/HIP: an optional linux-only 'hip' manifest entry (GGML_HIP, twelve GPU_TARGETS covering CDNA and RDNA2-4, the user's ROCm 7 linked through /opt/rocm/lib, never bundled). A default release builds what it always did; release.yml's hip input adds a hip job in rocm/dev-ubuntu-22.04 7.2.1 (pinned by digest), and rocm-compile.yml runs the same recipe by hand only (twelve targets take hours; nothing cancels it). Setup offers HIP only when every rocminfo GPU agent is a built target and ROCm 7's libamdhip64.so.7 is installed; Vulkan stays the default for AMD. - From the review: detection had accepted any gfx agent (and amd-smi), so setup would have preselected HIP on GPUs the bundle has no code for, aborting the backend on every launch; and a failed or timed-out hip job could block or silently strip the packages. A requested hip that did not build now stops only the Linux packages. Tests: 1053 passed (full suite), Electron 254, release check, native Metal smoke against the user's bridge and against a fresh Metal release bundle built from the new bridge (with its strict release check). The HIP bundle is proven only by the CI compile job; no AMD GPU was used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each tool's description is part of every prompt, and about half of a typical 5.8k-token prompt was tool definitions, which a 27B model reads at about 200 tokens/s before it can reply. tools.disabled lists tool names the registry never registers, so they are never offered, never run and cost no prompt tokens. It applies to every path (built-ins, desktop tools, TaskMCP, mcp.json servers and the session's own tools); a server's tool matches by its own name or the <server>__<name> it would run as. Settings validates it as a list of tool names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
three.js skips a mesh whose bounds leave the view, but a skinned mesh's bounds are its rest pose, so once the avatar is swung, dragged or animated, whole parts could blink out for frames (reported with the VRoid "brazilian miku" model when moving her). The avatar is always on screen, so its meshes are never culled, as three-vrm's examples do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ever faces away While an avatar was held, restore() copied the root's saved quaternion back, which makes three.js re-derive the Euler angles. A VRM 0.x root is turned by PI, so the angles came back as (PI, -yaw, PI), and the renderer's per-frame rotation.y write then produced a root facing away from the camera. The next restore turned it back, so front and back alternated every frame while held and through recovery: the avatar looked see-through, split into front and back shells, and was left facing away about half the time after recovery (reported with a VRoid model). step() now saves and restore() copies the Euler angles, which undoes step() exactly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The defaults folded a held avatar over. Joint stiffness 12 barely beat gravity 9.8, so held joints bent about 45 degrees (more on VRoid models, whose upper chest takes its direction from a bust bone), and threshold ragdoll started after 350 px of ordinary dragging and pinned every joint at the 60-degree limit. On a VRoid skeleton a chest grab bent the head 46 degrees and stretched the top's mesh to 2.9x; with body follow spring 40 and no ragdoll no bone bends more than 16 degrees and nothing stretches. Those are now the defaults; ragdoll stays available as an opt-in. Stored profiles keep their own values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d body Dragging, throwing or walking the avatar moves her window, not her skeleton, and VRoid springs are centred on the skeleton, so they never felt the motion: she moved as one rigid piece. AvatarInertia tracks the window's position each frame and adds its acceleration (plus a little air resistance) to every spring joint's world-space gravity for that update only: springs lag when she starts moving, stream behind while she moves and bounce when she stops. A jump between places is not motion. The new pickup.bounce setting (0-2, default 1, "Hair & cloth bounce when moved" in Pickup, ragdoll & recovery) scales it; 1 is about a third of physical force, since a brisk drag of a 1.6 m avatar shown 720 px tall is a 20 g jolt. On a VRoid model a brisk drag at 1 swings hair and bust about 30 degrees, and they settle within a few seconds; at 2 the hair still settles (no spring is left caught on a collider). The held body was too stiff to bounce. Damping 0.8 (was 2) and drag inertia 1.2 (was 0.5) let her wobble after a drag stops and after release, and the joint limit drops to 35 degrees (was 60), so she bounces without folding over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n VRM 0.x Her face never moved. With emotion off it sat on a blank neutral preset, surprise never resolved on VRoid models (their preset is 'Surprised'), and blinks snapped shut on a fixed timer. - avatar_face.mjs: expression names resolve robustly (exact, any case, VRM 0.x aliases). Face parts built from VRoid Fcl_* morphs (brows up, worried or angry, wide or squinting eyes, small or O mouth, fang) register when the model has them, within the 64-name capability limit. A layered mixer crossfades the base face (emotion, or a faint resting smile when there is none) against a reaction or a hit rule's expression without overshooting, and corrects blinks for expressions that already close the eyes. Blinks close fast, hold, open slower, sometimes double, and fire on events. - Reactions on today's events: a press blinks with a brow pop, a grab startles into surprise and eases into a worried, breathing face, release brings a relief blink and smile, a hair or bust tug winces. The API (react, sequence, blink) is what the hold state machine will drive. - MotionEngine now works in canonical VRM 1.0 axes and flips once for a VRM 0.x rig (negating x and z, as three-vrm-animation does for .vrma), reading the rest pose through the same flip, so .vrma clips are never flipped twice. On a VRoid model the idle arms were raised 63 degrees and nods went up first; now the arms hang down, nods go down and the wave raises the hand. VRM 1.0 models are unchanged. - Settings: face.restingSmile and face.intensity in the avatar studio. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng and a carry swing Every desktop companion studied (Desktop Mate, Mate Engine, VPet, Shimeji) holds the character in a designed pose and gets its bounce from springs and the face; none bends every joint under gravity, which is what made her fold like a limp doll. - avatar_hold.mjs: HoldBehaviour, a pure state machine (pressed, poked, grabbed, dangling, carried, dizzy, protesting, resigned, tugged, recovering) driven by the cursor's motion. Picking her up is a short scene: a startle and surprise, a worried breathing dangle with glances at the holder, alarm when carried fast, dizziness when shaken, a sulk with kicks after 6 s, a sigh and a limp hang after 18 s, a wince on a hair tug. HoldFace drives the face layer; it exposes a look target for the gaze. - avatar_held_pose.mjs: authored poses in VRM 1.0 axes (tuck, reach, swing, limp, startle, kicks, alarm, settle, breathing), tuned on a VRoid model to keep elbow and armpit stretch near walking's; the legs mirror smoothly to the trailing side. - AvatarPickup is now a carry rig: the scruff hold slides her so the cursor holds her upper back (kept through a wheel resize and into the saved position); one pendulum on the hips (1 Hz, damping ratio 0.28, 35-degree soft limit, air lean) runs before the springs, so the hair swings with her; legs and arms lag, the head stays level, and release settles her on her feet with no bounce or sliding. The per-bone gravity torque, the root swing and the linear 2 s blend are gone; opt-in ragdoll is a limp profile. She never faces away. - MotionEngine takes the held pose and its crossfade from the hold. AvatarInertia.shift keeps a resize from reading as motion. The scruff slide and drag geometry live in avatar_geometry.mjs with tests. - Settings: one shared pickup and reactions field list for the studio and settings search; the old pickup keys (gravity, damping, bodySpring, jointLimit, inertia, maxSwing) are dropped, so stored values cannot bring the fold back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…el smoke test - avatar_drop.mjs: release. A flick at or above the throw speed (900 px/s by default) throws her with the cursor's velocity, even off the taskbar; she falls under gravity and air drag, bounces off the work area's walls (re-measured from her outline every frame, so her arms stay on screen), and lands on the floor line of the display she is on with her shoe tips planted. A gentle release sets her down in place, gliding onto the floor when her feet are close to it (this fixes the foot-grab drop), and never leaves her below it. Pressing her mid-fall catches her. The position is saved once she has landed and the camera has settled, and on unmount mid-flight. - HoldBehaviour gains falling, landing and recovering: arms flung out and eyes wide in the air, a wince at a wall bonk, eyes squeezed shut and knees bent on landing, a squash that springs back with a small overshoot, then a pout at the cursor after a throw or a sigh and a look back after a soft drop. No skating after contact. - Gaze: saccades with fixation flicks instead of gliding (gaze.saccades, on by default), timed by amplitude; a glance up at the holder on grab, then the viewer, occasional holder glances, the floor while falling; the head joins past the eyes' range; big shifts blink at most every 2 s. No look target means normal tracking in every state. - Settings: release style, throw speed, fall gravity, landing squash and quick eye jumps join the shared list (studio and search); recoverySeconds keeps its key, now "Landing and recovery time". - avatar_vrm_smoke.test.mjs: runs under npm test only when RIKO_TEST_VRM names a .vrm file; checks resting arms, the dangling silhouette, a decaying carry swing, a throw that lands and recovers, the face, no face-away frames and joint limits. Known: on brisk drags and shakes the camera safety net still shrinks her (to 73-85% for 0.6-1.6 s); a wider canvas or a compensated pan would fix it but needs an on-screen check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…timate, and macOS/Linux activity V175: GPU telemetry read only nvidia-smi, so Metal, ROCm, Intel and AMD always showed "nvidia-smi unavailable", and no estimate could project. - riko_devices, a sixth bridge export: ggml's devices as JSON (name, description, backend, type, PCI id, free and total), from the same queries llama.cpp's own load uses, so it covers CUDA, HIP, Vulkan and Metal (whose figure is this process's share of its working set). Safe from any thread once riko_create has run. It is optional at runtime: an older bridge (no export) falls back cleanly; newly built bundles, the release check and the native smoke require it. - resources/gpu_sources.py: telemetry sources in order (the bridge's devices, nvidia-smi, amd-smi or rocm-smi and amdgpu sysfs on Linux, any-vendor DXGI plus PDH through ctypes on Windows instead of a PowerShell call every 3 s), merged per device; integrated GPUs take ggml's shared budget, not their small dedicated slice. Process identity on macOS via libproc. The UI keys devices by uuid or index, shows the device the model runs on and labels unified memory; the Discord /resources command follows. V316: the estimate matches runtime.main_gpu through ggml's device list (not nvidia-smi's PCI order), budgets torch models where they actually run (no CUDA context on a Mac), names the compute row neutrally, and has a unified-memory mode: the budget is the smaller of Metal's working set and 75% of RAM, with no projection when no layers are offloaded. V180: computer awareness was Windows-only and claimed 'enabled' with no data. runtime/activity.py: an adapter per platform (Windows as before; macOS idle via CoreGraphics and the frontmost app via lsappinfo, no permission needed; Linux idle over D-Bus with retries; a null adapter otherwise), capabilities in the snapshot, unsupported toggles disabled in the initiative settings, and perception status from the last successful sample. Runtime observations ignore timestamps and count idle in whole minutes, so they no longer churn the prompt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aths, MCP, memory, logs) V148: Settings no longer returns secret values. Each secret field comes back masked (a key set only in RIKO_API_KEY shows empty with a note); saving the untouched mask keeps the stored value, clearing it still works after the confirmation, and the confirmation dialog shows a fingerprint, never the key. The path check returns only what the UI needs. V149: remote Markdown images become click-to-open links instead of loading silently (a prompt-injection exfiltration channel); local and /api/media images are unchanged. index.html gets a Content-Security- Policy (self, data:, blob:, the loopback backend on any port; Vite's HMR socket only while it serves the page), a doctype and a charset. Checked in Electron from file://: scripts, styles, KaTeX fonts and images load with no violations. V146: kernel/safe_paths.py checks paths as text before any filesystem call: network and device paths, Windows drive-relative paths, alternate streams, reserved names and lexical escapes are refused, then the resolved path is re-checked for symlinks. Media, avatar and animation import, the avatar model route, effect assets, the path check and the resource estimate all go through it; tests run the Windows cases on every OS. V153: MCP servers get a minimal environment (PATH and a safe set plus their declared env, or inherit_env by choice) instead of the backend's whole one; their tool descriptions, parameters, outputs and errors reach the model framed as untrusted and trimmed; MCP tools ask for approval by default unless allowed in Settings or the server sets "require_approval": false. V154: recalled memories are framed as recalled data that may contain other people's text and is never an instruction. V105: the console log handler (and uvicorn's) redacts secrets too; the packaged backend-launch.log is capped at 5 MiB with two backups. V157: character_config.yaml.previous is written 0600 like the config. V132: .gitignore covers user data and secret-bearing copies at any depth (persistent_memories, mcp.json, .env files, settings temps and backups, logs, models, wake words) with nothing tracked becoming ignored; character_files/main_sample.wav stays tracked by an explicit exception pending its licence and consent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d an opt-in echo gate V051: Julia-1 ran whatever Python its Hugging Face repo held, imported as 'julia' from sys.path[0], with two warmups racing to define it. Both call sites now download the pinned commit a85b127 (configurable), check its 18 code files against a SHA-256 manifest (kernel/model_pins.py), and import it under a unique riko_julia_<hash> name through a finder that compiles only the checked source bytes (never bytecode, extension modules or unlisted files); sys.path is untouched, the two call sites share one load, and a cached snapshot still loads offline. The probe's identity keys are unchanged. V150: every GitHub action is pinned to a full commit SHA (tag in a comment); the Linux and Windows Vulkan SDK downloads are checked against SHA-256 sums recorded from LunarG before they are extracted or run, and the Windows installer's exit code is checked; stale comments and the release README are corrected, with the Windows code-signing options written down. electron-builder, Python dependencies, the ROCm image and llama.cpp were already pinned. V073: Riko's own voice through laptop speakers interrupted her after 1.5 s and was saved as the user speaking over her. voice.barge_in adds opt-in policies: echo_gate learns her echo level at the start of every reply (from the exact blocks SpeechQueue writes) and lets an interruption through only when the microphone is clearly louder than her echo; stop_only ignores voice during playback and relies on Stop. Gated echo fragments are not transcribed. The default, full_duplex, is unchanged. A proper echo canceller is documented as the follow-up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wave 6: opt-in ROCm, backend-neutral telemetry, security hardening, supply-chain pins; plus app and avatar fixes
Measured with the UI audit's offscreen harness, HEAD against this build, back to back on the same stub (CPU is % of one core): - Chat: Message, StreamChat and FormattedText are memoized and the markdown plugins hoisted, so a streamed token re-renders only the streaming message: 8.2 ms and 184 components per token at 40 messages -> 1.8 ms and 12; in the dock 8.4 ms -> 0.4 ms (it no longer renders the list there). - Snapshots: runtime_store.mjs keeps every unchanged part of a snapshot as the previous object and useRuntime(select) subscribes to fields, so a state.snapshot re-renders what changed: 9.7-13.8 ms and up to 336 components -> about 0.5 ms and 1. The reducer keeps its state when nothing it holds changed; only model and chat errors stick as the chat error (cleared by the next completed turn), others are an 8 s notice. - Avatar: avatar_frame_rate.mjs paces the loop: about 30 fps standing still, the display rate while she moves, is held, talks or her eyes follow the pointer (and 1 s after, while springs settle), paused when asleep and settled. One full matrix update per frame instead of four. Idle at 120 Hz 20% -> 7.8% of a core; asleep 13.7% -> 0.4%. A setting (Settings > Graphics: adaptive or always full). An exception in one frame no longer stops the loop. - Traffic: hit-test IPC and popup interactivity only on change (116/s -> 0.1/s with the cursor away; dock hover 58/s -> 0.1/s); pointer POSTs only when the near flag flips, with a 1 s keep-alive. - Idle CSS border animations stop when only the mic is waiting (reduce- motion still applies): the full chat and dock no longer burn 2-4% idle. - Bugs: the avatar mounts after the first snapshot (no 'Avatar model could not be loaded' flash, no Mita.vrm fetch); capabilities and the whiteboard capture are re-sent after a backend restart; an error boundary per route shows a Reload card instead of a blank window; whiteboard bounds no longer snap back mid-save; a display change during a post is sent once it ends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
UI Phase 0: speed and bug pass
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.