Local Studio v2.0.1 full convergence and release gate - #408
Draft
0xSero wants to merge 267 commits into
Draft
Conversation
… blocked
Standing up a runtime + frontend against a synthetic long session, so anything
can be measured in a real browser, cost most of an iteration and none of it is
discoverable from the code. Written down with the three traps that each fail
silently:
- WORKSPACE_ROOTS is enforced by the runtime and the frontend independently,
and GET /api/agent/sessions/:id answers {events: []} rather than an error when
a path is outside the roots — so a rejected request reads as an empty session.
- The roots must include the real home dir, not just the scratch dir: the
sidebar queries a "Chats" pseudo-project at ~/.local-studio.
- `bun --cwd X run src/server.ts` resolves as a package script and prints the
script list instead of starting the server.
Still blocked with all of that green: the runtime serves 501 events, the
frontend proxies them, the project is registered and selected — but the sidebar
never lists the session, so the timeline never mounts and there is no browser
measurement this round. GET /api/agent/sessions?cwd=… returns it correctly, so
the gap sits between that response and the sidebar. Whether that is
harness-specific or a real bug in listing sessions for a freshly added project
is the next thing to find out.
Also noted, so it is not later mistaken for a storm: 7 identical
GET /api/agent/runtime/sessions land within 6ms at mount. Steady state is
correct at one per 5s.
No production change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion The sidebar gap recorded last round was not a bug. session-rows.tsx renders ProjectSessions only when the project row is expanded; the click that looked like it should expand had selected the project instead. Corrected in the ledger so nobody hunts it. With that unblocked, the first real browser measurements on an 800-turn session: Opening paints 250 merged bubbles from a 500-event tail — 4,516 DOM nodes, ~18 per message. Scrolling never becomes the problem: at 1,250 messages and 21,016 nodes across a 317,567px scroll height, a scroll jump still costs 6-9ms, inside a frame budget. "Load earlier" is the cost. Each page adds a constant 250 messages, but the click goes 635ms -> 1001ms -> 1891ms as the transcript grows. The fetch behind those same pages took 14-40ms for 303KB, so ~95%+ of that latency is client side — fold, merge, and React reconciling a list that keeps growing — not the server. The six server-side findings did their job; what remains is render. That narrows virtualization considerably: it is NOT justified by scroll jank, because there is none. If it is justified at all it is to bound the mount and reconcile cost of loading earlier history, which means bounding mounted subtrees rather than the scroll container. The ledger had been carrying a much broader claim. Caveats recorded with the numbers: the synthetic transcript is plain text with no tool blocks or diffs, so node counts are a floor; and requestAnimationFrame never ticks while the browser pane is hidden, so rAF-based frame timing hangs silently. No production change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reloading a session showed a truncated transcript with no way back to the rest of it. Measured on an 800-turn session: with the cache present a reload painted 100 messages, no "Load earlier", and zero replay fetches; with the cache cleared it painted 250, showed the affordance, and fetched once. One clause causes it. chat-pane-hooks skips the canonical replay for a session that "already has messages", but loadInitialFromStorage seeds messages from the localStorage snapshot before that runs. The snapshot is a deliberately lossy placeholder — capped at 200 messages and 512KB, carrying no history cursor — so it satisfied the guard, the replay never ran, and the session was stuck showing whatever fit in the cache. Navigating away and back was the only way out. Nothing about it fails loudly; the transcript is just quietly short. Mark snapshot-seeded messages hydratedFromCache so the guard can tell a placeholder from the real transcript, and clear the flag once a replay lands. The cache keeps its purpose — the reloaded session still paints in 2ms — and the replay now runs behind it: 250 messages, affordance present, one fetch. Tests pin the marking rather than the symptom, since the symptom is silent. Found while trying to attribute the 635-1891ms "load earlier" click, which remains unattributed and is recorded as such. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ruled the candidates out one at a time rather than reasoning about them. The fetch is 14-40ms, the fold ~1ms, the merge 0.42ms, the snapshot write 0.2ms, and a pure state-change re-render at 1250 messages produces zero long tasks — so reconciliation of the existing list is not the cost either. What is left is mounting: ~3.5ms per newly mounted message. A page is 250 messages, so the click is ~0.9s of main-thread JS. The content-visibility A/B is what makes that solid. It skips layout and paint for offscreen subtrees but cannot skip React mounting or markdown parsing, so a per-message cost that barely moves (3.59ms -> 3.42ms across 250-mounted and 100-mounted runs) says the work is JS. It also rules out CSS containment as the fix, which was the cheap thing worth trying first. Corrects an earlier entry in this ledger: the cost does NOT grow with transcript length. Per-message cost is flat; the apparent growth was long-task chunking and a busier browser. What is constant is the page size. Leaves two candidate fixes recorded and unattempted: mount fewer messages per page (a UX trade), or make a message cheaper to mount — 3.5ms is a lot for mostly-plain text, and assistant-markdown runs ReactMarkdown + remark-gfm on every message. No production change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The obvious next move was a fast path in assistant-markdown: skip ReactMarkdown for messages that contain no markdown. Measured it first. The whole markdown pipeline is 0.288ms for plain text and 0.687ms for marked-up text, against a 3.5ms per-message mount cost. A plain-text fast path would save 0.28ms — 8% — and only on messages that happen to be plain. Not worth it: misclassifying a marked-up message renders its syntax as literal text, a visible regression traded for nothing. The benchmark measures marked-up text as well as plain on purpose. Measuring only plain text — which is all the synthetic transcript in the harness contains — would have made the fast path look several times better than it is, because the case it helps is the case the fixture over-represents. So the 3.5ms is the aggregate message subtree, not a hotspot inside it. There is nothing single to make cheaper. Which finally justifies virtualization on a number: at ~3.5ms per mounted message, a 250-message page costs ~0.9s, and that is paid on the initial session open too, not just "load earlier". Against a server that now answers in 30-60ms, mounting is the dominant cost of opening a session. Worth noting the justification differs from the one this ledger carried for most of the pass. It is not scroll jank — finding 9 measured scrolling at 6-9ms and found none. It is never mounting 250 subtrees at once. No production change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Last round extrapolated 3.5ms x 250 messages and concluded a cold session open spends ~0.9s mounting. Measured it directly with buffered longtask entries instead: two cold opens with the snapshot cleared came in at 204ms and 230ms total blocking, fetch included. So mounting into an empty list is ~0.9ms per message, not 3.5ms. The 3.5ms figure came from prepending 250 messages into a list already holding 1000+. Both are real and they measure different things: mounting is cheap, prepending into a long keyed list is what costs. That also explains finding 11's other result — re-rendering the same list in place produced zero long tasks, because a prepend shifts every key while an in-place render lets every memo bail. Which removes the case for virtualizing. Cold open 220ms, scrolling 6-9ms at 1250 messages, and the only slow path is repeated "load earlier" at ~0.9s — an explicit action with a pending state, on a transcript already ~1000 deep. Weighed against putting stick-to-bottom, scroll restore, the ResizeObserver pin and native scroll anchoring at risk, each documented as fixing a specific bug, it is not worth it. Halving the page size is the cheap lever if it ever is. Also recorded: the first attempt at this read 10.2s, because performance.now() counts from navigation while the polling only began when the probe ran. It measured when the probe looked, not when the work happened. No production change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… fills The transcript cache's own ceiling is 24 sessions x 512KB, about 12.6MB, against the ~5MB most browsers give an origin. On overflow it removed every other session's entry and retried. Driving 24 writes into a 5MB quota: text-only transcripts (~98KB each) never overflow, but tool-heavy ones (~506KB each) went 1..10, collapsed to 1, climbed to 10, collapsed again. So with tool-heavy sessions every cached transcript was lost roughly every tenth write, and the cache that exists to make reopening instant was empty exactly when the most sessions were open. Nothing visible — sessions just stop reopening fast. Drop the least-recently-updated entries one at a time until the write fits instead. Same 24 writes now hold steady at 10 cached sessions with zero mass evictions, ending at 10 rather than 4. Three tests pin it, including that an entry too large to ever fit is dropped rather than thrown; the cache must never surface as an error. The measurement error is worth recording too. The first version of the harness hardcoded length: 0 on its fake storage, and cacheKeys walks 0..length-1 — so every eviction path silently no-opped and the run reported the exact shape of a healthy cache. It read as proof there was no problem. length has to be a live getter, and the test says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifteen session switches across eight sessions, measured with performance.memory. Heap climbs while bounded caches fill — 12.7-22.5MB on the first pass, 23.9-29.4MB on the second — then plateaus: the third pass moves 1MB across five switches against 12MB over the first ten. DOM node count never moves at 2058, because only the active pane's session is mounted and pruneSessions drops the one it replaced. That is caches warming, not a leak. Three passes would not catch a slow one, but nothing here scales with the number of sessions visited. Nothing to fix. Closes the pass with a summary of what landed and, more usefully, what was checked and rejected so nobody rebuilds it: timeline virtualization (cold open is 220ms, scrolling 6-9ms), a markdown fast path (the pipeline is under 0.69ms of a 3.5ms mount), skipping the active-branch walk for "linear" sessions (it drops 69% of entries on a real rollout), and LRU for the merge cache (a sequential walk longer than the cache evicts exactly what it needs next). Session opens went from ~1.2s per process, and ~32s for the 3.56GB rollout, to ~30-60ms server-side and ~220ms cold in the browser. The largest remaining lever is not in this repo: two pi extensions write 91-95% of the bytes in these rollouts. No production change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bridge was a second, uncached session API: its own rollout discovery, header reads, pagination, cursors and event translation, sharing exactly one function with sessions-store. It was never wired to anything in this repo and never carried load — the on-disk idempotency ledger holds three agent turns, all from 2026-07-20, and nothing since. Litter reaches the same sessions another way. Its pi bridge reads ~/.pi/agent/sessions/ directly: 1,403 threads, 82 of them in the Local Studio workspace, 19 of those active after the gateway's last request. Watching the live daemon for five minutes showed zero loopback connections — every socket went to a remote relay on :443, and every connection to the agent-runtime port had Local Studio on both ends. promptDurably and persistLitterPromptBoundary go with it. They existed only so the bridge could correlate a mobile dispatch with a transcript entry, and nothing else ever read the local_studio_litter_turn_v1 marker they wrote. -7,366 lines. KittyLitter QR pairing is untouched — it shells out to the kittylitter CLI and never involved this endpoint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ported the structure from the ChatGPT desktop bundle's plugins page: a section is a header plus a bounded card, rows live inside that card, and the hairline between rows is inset from the border rather than full-bleed. A `divide-y` that runs into the card edge reads as a spreadsheet; the inset makes each row read as its own object, which is the whole difference in feel. Rows become flex instead of a two-column grid. The old grid pinned every label to a fixed 180px/260px track, so short labels left a dead gap and the value cell floated in the middle of nowhere. Now the label takes the space it needs and trailing content — value, status, actions — sits against the right edge. Progressive disclosure: expanded content renders in a nested rounded panel instead of hanging off a hardcoded left margin, and interactive rows with children get a chevron that turns. `expanded` is parent-owned and optional, so existing callers are unaffected. Kept our type ramp rather than Codex's absolute sizes — the structure is what was wrong, not the density. Also fixes the recipe editor's backdrop, which still used the opaque `--color-background` and made the app appear to vanish behind the drawer. Same bug already fixed in ui/modal.tsx; this overlay was hand-rolled and missed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mic button in the composer was broken on this deployment, and had been since it shipped. It records audio and POSTs to the controller's /v1/audio/transcriptions with a `file` and a `mode` and no `model`; the controller falls back to LOCAL_STUDIO_STT_MODEL, which was never set, and throws model_missing. `mode: best_effort` does not save it — mode only feeds the service lease, which is checked after model resolution. So every press recorded the microphone and then failed with a 400. Dictation belongs on the machine the microphone is attached to anyway. A laptop has an accelerator, these models are a few hundred MB, and it removes a round trip to a GPU box over the tailnet for two seconds of speech. Engines are probed in order — parakeet-cli, whisper-cli, mlx_whisper — and the first usable one wins, because which of these a machine has is not something the app can know. parakeet and whisper.cpp need a model path (LOCAL_STUDIO_PARAKEET_MODEL / LOCAL_STUDIO_WHISPER_MODEL); mlx_whisper fetches and caches its own, which makes it the dependable last resort. Everything goes through ffmpeg to 16kHz mono first: browsers hand us webm/opus or mp4 and none of these engines read either. Verified end to end on an 11s webm recording — resolved mlx-whisper, returned the correct transcript in 9.6s including model load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Installed 2026-07-10, reports ready, 9.5GB on disk — a 5.7GB venv plus 3.8GB of weights — and it has synthesized nothing. The voices, uploads and outputs directories are all still empty, the worker is stopped, and voice_count is 0. Chatterbox cannot speak without a voice profile and none was ever recorded. modules/speech was the largest module in the controller (3,376 lines) for a feature with no recorded use. Going with it: the audio routes, the stt and tts services, the speech contract, the desktop plugin bundle, the chatterbox voice UI, and the speech API client. The plugin runtime's hostCapability mechanism goes too — it was hardcoded to the chatterbox-voice plugin id. Two things fell out as dead once speech left, both found by knip rather than by me: nvidia-compute-processes.ts, and boundedFormData, whose only caller was multipart audio upload. The GPU lease registry also went. It had two owners, "llm" and "speech"; the llm owner only validated against the instance record it already had, so the speech worker's pinned hold was the entire reason it existed. What survives is recipe GPU-visibility resolution, which is what compute/bridge.ts actually uses, so gpu-leases.ts becomes gpu-visibility.ts. Speech-to-text is unaffected — it moved to the local machine one commit ago. The 9.5GB on the controller is untouched; deleting it is a separate call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two /v1/audio routes they sized are gone with chatterbox. Caught by a grep sweep rather than by a gate — nothing type-checks a route string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings the 11-commit v2.10/v2.11 stabilization line (origin/main eeeb340) back onto the dev line inside the consolidation PR, healing the reverse divergence (C2). Merge parents: 733c93a (track) + eeeb340 (origin/main). # Conflicts: # frontend/desktop/project.mjs # frontend/src/features/security/request-boundary.test.ts
(cherry picked from commit 02930e7a1a6aeb329a1142b535e65900dd9c8866)
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.
This is the single Local Studio v2.0.1 convergence track. It remains intentionally draft and is not ready to merge. The current source checkpoint is locally green; architecture, simplification, API compatibility, UI, cross-app sync, performance, installed-app, browser, mobile, and release gates remain incomplete.
Controlling goal
1a2205e95a56a154691654ddc0d0547dbd60f491.5d2168744ad7cfa1f009e7d63f00068ab1490a69.cd1e3cc81c4e9754012e982538912c4e37c4780ce4c7f56849d7ffb67f32f2b6.9e5931861d3cd682820681d41c9f8c622451bbb3d0c69ad6408b53ec7d410ef4.Current checkpoint
dev; head:feat/v201-consolidationat exact documentation commit1a2205e95.mainandorigin/mainare botheeeb3406d4bcef255b6405c5508fb324d5e38e77;origin/devisa765eb27bca4baffabc6dc84c553fc6d8be5590d.ControllerRpc, trustedrpcJson<Result>casts, and excluded Responses/Anthropic/status/config/proxy/multi-port routes remain explicit debt.controller.db/chats.dbcopying. It does not provide schema versioning, backup, restore, one database owner, or approved table/vault disposition.--timeout 0method.5d2168744at evidence head8e26cf808passed full localnpm run check; transcript SHA-256 is0f69ee26da11da69631e04fcbcd6ea44b5705ea03b1ad22d0b785a172d8d1cc9and exit-marker SHA-256 is8cc38b25ab4433344e61256823d8fad590263bdfc33ce2d8e937e5338ccac505.55c938f88passed all eight jobs in run 31893349705 plus separate head-bound CodeQL. RPC/docs head13cde15bapassed all eight jobs in run 31892861404 plus separate head-bound CodeQL.1a2205e95passed all eight workflow jobs in run 31893957766 plus the separate head-bound CodeQL context. This is source/package proof, not installed or release acceptance.v2.11.2; installed Dev 2.1.0 has no proven source commit. Neither is this candidate.7ce9d137installed but is unavailable. Validated local Litter source865c7bd5is newer and not installed. No paired bidirectional send/receive/reconnect proof exists.Major blockers
devto protectedmain, resolve release signing, and delete only user-approved proven-dead worktrees/branches.Evidence
Do not mark this PR ready, merge it, close other PRs, promote a release, or delete worktrees/branches until the corresponding
GOAL.mdrows and gates areDONEwith exact evidence.