Skip to content

Local Studio v2.0.1 full convergence and release gate - #408

Draft
0xSero wants to merge 267 commits into
devfrom
feat/v201-consolidation
Draft

Local Studio v2.0.1 full convergence and release gate#408
0xSero wants to merge 267 commits into
devfrom
feat/v201-consolidation

Conversation

@0xSero

@0xSero 0xSero commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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

  • GOAL.md is the sole requirements, status, execution, and acceptance contract.
  • Current documentation head: 1a2205e95a56a154691654ddc0d0547dbd60f491.
  • Current application product head: 5d2168744ad7cfa1f009e7d63f00068ab1490a69.
  • Goal file SHA-256: cd1e3cc81c4e9754012e982538912c4e37c4780ce4c7f56849d7ffb67f32f2b6.
  • External checkpoint-manifest SHA-256: 9e5931861d3cd682820681d41c9f8c622451bbb3d0c69ad6408b53ec7d410ef4.
  • Every requirement remains merge-blocking unless the user explicitly changes scope.

Current checkpoint

  • Base: dev; head: feat/v201-consolidation at exact documentation commit 1a2205e95.
  • Local main and origin/main are both eeeb3406d4bcef255b6405c5508fb324d5e38e77; origin/dev is a765eb27bca4baffabc6dc84c553fc6d8be5590d.
  • The accepted setup/runtime RPC slice moves 23 bounded facade methods onto shared DTOs and the existing Hono RPC transport. Manual ControllerRpc, trusted rpcJson<Result> casts, and excluded Responses/Anthropic/status/config/proxy/multi-port routes remain explicit debt.
  • Database Safety Stage A stops drop-on-open and raw controller.db/chats.db copying. It does not provide schema versioning, backup, restore, one database owner, or approved table/vault disposition.
  • The recipe-editor slice derives editor values from canonical contracts, binds common fields once, and replaces four duplicated capability tabs with one renderer. Independent review returned GO with no P0-P2 finding; exact save/reload/command and rendered-structure probes passed.
  • The recipe browser attempt is invalid: its process tree was not proven isolated from live controller settings and hydration issued two live settings requests. No manual recipe mutation occurred, no credential value was inspected, no preimage exists, and no restoration was attempted. Browser/keyboard/focus/installed acceptance remains open.
  • Frozen product LOC is 102,732 across 803 files against a 107,556 baseline and required target of at most 80,667. Another 22,065 product lines must be removed under the identical pinned cloc 2.06 --timeout 0 method.
  • Exact product 5d2168744 at evidence head 8e26cf808 passed full local npm run check; transcript SHA-256 is 0f69ee26da11da69631e04fcbcd6ea44b5705ea03b1ad22d0b785a172d8d1cc9 and exit-marker SHA-256 is 8cc38b25ab4433344e61256823d8fad590263bdfc33ce2d8e937e5338ccac505.
  • Database Safety Stage A head 55c938f88 passed all eight jobs in run 31893349705 plus separate head-bound CodeQL. RPC/docs head 13cde15ba passed all eight jobs in run 31892861404 plus separate head-bound CodeQL.
  • Current head 1a2205e95 passed all eight workflow jobs in run 31893957766 plus the separate head-bound CodeQL context. This is source/package proof, not installed or release acceptance.
  • Installed Stable remains 2.11.2 and byte-tied to old tag v2.11.2; installed Dev 2.1.0 has no proven source commit. Neither is this candidate.
  • The physical iPhone has Litter 2.0.1 build 200010003 from diagnostic source 7ce9d137 installed but is unavailable. Validated local Litter source 865c7bd5 is newer and not installed. No paired bidirectional send/receive/reconnect proof exists.

Major blockers

  • Complete controller convergence on pinned Effect v4 and one typed RPC/contract model.
  • Implement and live-verify OpenAI Responses passthrough, Anthropic passthrough, and multiple simultaneous arbitrary-port deployments.
  • Complete safe database versioning/backup/restore/ownership and separately adjudicate every table/vault disposition.
  • Finish the product-only 25% LOC reduction and retained route/page/config/dependency census.
  • Finish Recents, New task, spinners, chat automations, bidirectional Litter session sync, Git drawer, browser tools, chat/tool rendering, editor, icons, Models, and whole-app Codex parity.
  • Prove exact-source packaged and installed Electron behavior, visible Brave-extension behavior, and matching physical-phone Litter behavior with recordings and frozen performance/size measurements.
  • Resolve the documented raw service-output, connector authority/environment/artifact/private-file, runtime ownership, and cross-platform security gaps.
  • Obtain final GLM-5.3, DeepSeek-v4-pro, Fable-5, Kimi-K3, and Opus-5 approvals at one frozen release SHA.
  • Harvest or explicitly reject every remaining PR, promote through dev to protected main, 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.md rows and gates are DONE with exact evidence.

0xSero and others added 30 commits August 11, 2026 23:53
… 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
0xSero added 30 commits August 15, 2026 10:33
(cherry picked from commit 02930e7a1a6aeb329a1142b535e65900dd9c8866)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants