Skip to content

feat(agent): match Codex threads and scheduled tasks - #407

Draft
0xSero wants to merge 102 commits into
devfrom
codex/codex-ui-parity-20260812
Draft

feat(agent): match Codex threads and scheduled tasks#407
0xSero wants to merge 102 commits into
devfrom
codex/codex-ui-parity-20260812

Conversation

@0xSero

@0xSero 0xSero commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Rebuilds the Local Studio agent workspace around Codex tasks while removing competing session and voice implementations.

  • Matches the Codex desktop shell across fullscreen and narrow layouts: sidebar density, typography, transcript width, composer geometry, task rows, activity states, and tool previews.
  • Establishes one canonical Thread repository over Pi JSONL plus metadata. Threads own turns/items, durable parent-child links, archive state, titles, and approximately 200k-token replay windows.
  • Fixes disappearing new tasks end to end: a durable provisional inventory is registered before the first assistant event; sidebar loads are ordered, abortable, schema-validated, and retain prior valid rows on transient failures.
  • Reconciles externally created Litter and Scheduled Task threads while the sidebar remains visible, using one serialized visible-only owner with no overlapping refreshes.
  • Keeps subagent tasks durable and nested before execution begins, including restart/failure persistence, parent archive cascading, exact pinned-family lookup, and deduplicated recursive rendering.
  • Rebuilds Scheduled Tasks in the Codex style with search/filter/detail/editor/history, thread-native controls, deterministic model fallback, requested-versus-actual provenance, isolated runtimes, bounded overdue execution, atomic completion/delete, and archive coordination.
  • Renders referenced local images, video, and audio inline with authenticated byte-range streaming; remote URLs are not fetched implicitly.
  • Restyles Models and Configure as bounded Codex-style resource cards.
  • Removes the obsolete Litter HTTP gateway, Chatterbox text-to-speech stack, dead dependencies, duplicate catalog code, and orphaned controller probes. Speech-to-text/dictation and KittyLitter QR pairing remain.

Architecture and safety

  • ThreadRepository is the sole durable task inventory owner; frontend state is an ordered, lossy projection.
  • Stable Pi Thread IDs and on-disk paths do not change.
  • Legacy /api/agent/sessions*, ?session=, and Pi naming remain compatibility adapters.
  • Archive remains the only hide/delete signal; transcript JSONL is never unlinked by the UI.
  • Provisional task identity can be resumed only for an exact cwd-matching, unarchived record with a definitively missing rollout; invalid and ambiguous IDs reject.
  • JSONL materialization deduplicates against provisional inventory and the durable rollout wins.
  • The default task read returns one typed Thread window. Explicit legacy tail/cursor requests retain the old events adapter.
  • External refresh preserves a working runtime on candidate failure, keeps event sequence monotonic, and never rewrites legacy transcripts.
  • Scheduled Task runtimes cannot be selected by interactive task lookup and are stopped and evicted after each run.
  • Local media is limited to canonical registered project roots, strictly contained beneath the selected task cwd, opened once with O_NOFOLLOW, and streamed/stat-ed through the same descriptor.
  • Only the explicit Electron LOCAL_STUDIO_DESKTOP=1 marker may bypass a configured frontend token.
  • No automated test files were added.

Measured change

  • 127 files changed
  • 4,618 lines added / 15,635 lines deleted
  • net 11,017 lines removed
  • standalone agent runtime: 13.77 MB

Validation

  • npm run check passed at 3e8f82b:
    • contracts, structure, release, UI ownership, and controller standards
    • ESLint and all TypeScript programs
    • circular-dependency, dead-code, dependency, and clone scans
    • production Next build and standalone agent-runtime build
    • repository-mandated existing suites
  • Final CI run 31670777709 passes every job:
    • gates, controller, agent-runtime, frontend production/browser acceptance
    • Linux desktop package/smoke and artifact upload
    • secret scanning, dependency review, CodeQL Analysis, and aggregate CodeQL policy
  • Independent Claude Opus 5/max release review: GO, no P0/P1 findings.
  • npm --prefix frontend run desktop:dist:dev passed at the final head, including physical Electron Helper and packaged Pi CLI gates.
  • Built app passed deep code-sign verification.
  • Installed with scripts/install-desktop-app.sh dev at /Applications/Local Studio Dev.app.
  • Installed app.asar exactly matches the built artifact:
    • SHA256 321222c78aa8dcda2eb7d675743f63646db6ecec09db0ad8526beaed9f171568
  • Installed Next, desktop-health, and agent-runtime health endpoints return ok.

Manual acceptance status

The requested hands-on Google Chrome plugin matrix is still pending because Chrome does not have the ChatGPT browser extension installed; Codex reports Browser is not available: chrome. Install or enable it from Settings -> Computer use, then run the matrix below. This PR remains draft and must not be merged until that acceptance is complete.

Chrome matrix

  • Fullscreen, narrow desktop, and coarse-pointer tablet task layout.
  • Create a task and restart before the first assistant event: one row persists; materialization does not duplicate it; archive/unarchive removes/restores it once.
  • Keep an expanded project continuously visible while Litter and a global Scheduled Task create tasks: each appears within 5.3 seconds without refocusing.
  • Delay and fail a list request in DevTools while firing repeated refreshes: no overlapping same-list requests, prior rows remain, and one pending reload follows.
  • Validate newest-first exact limits with mixed provisional and materialized tasks and no duplicates.
  • Pin parent only, child only, and both; activate parent/child; each family remains recursively nested exactly once.
  • Compare /api/agent/sessions/all?ids= with includeDescendants=1: default returns the exact root; opt-in returns only the exact family.
  • Collapse/remount/background/foreground the sidebar: no duplicate timers/listeners, polling pauses hidden and resumes visible.
  • Task switching, scroll restoration, older-history paging, far prompt jumps, and approximately 200k-token windows.
  • Scheduled Task create/edit/run-now/already-running/archive/unarchive/model-fallback states.
  • PNG/JPEG/video/audio response references and seekable 206 playback.
  • Models and Configure rows/disclosures plus tool/reasoning popovers near transcript edges.

0xSero and others added 30 commits August 12, 2026 18:47
The reading column, its composer and the prompt rail each measured
themselves differently, so they only agreed at one window size. The
column was sized from `48vw` while it actually lives inside a pane whose
width also depends on the sidebar, the split layout and the right panel;
the rail was pinned to the pane's left edge and drifted further from the
thread the wider the window got, stranding itself in the empty gutter at
fullscreen.

All three now measure against one fixed 48rem column, centred in
whatever pane they land in. The sidebar keeps its own scroller so
destinations stay put while the session list scrolls, and the session
route no longer nests a second scroller around the workbench.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sidebar rows sat a pixel short of the Codex nav-row pitch and mixed a
13px title with 12px metadata, so titles and dates read as one weight.
Rows are now 31px with a 14/20 title over 12/16 metadata, and the list
carries the states a session actually reaches: a failed last turn earns
its own marker beside running, unseen and finished, archiving asks on
the row instead of firing on click, and the empty and loading rows use
the metadata scale.

The list also opens ten sessions deep rather than five, and keeps the
running or focused session rendered even when it falls past that limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A finished turn already collapses to one semantic row ("Ran 6 commands ·
edited 3 files"), but opening it handed back framed cards: bordered,
filled surfaces, and a "Changes" bar above every diff. The frame carried
no information the row above it did not, and three nested frames in one
turn read as a control panel rather than a transcript.

Expanded detail now hangs off a left rule like the rest of the group —
plain scrollback for commands, plain output for tools, a bare +/− count
above diff rows — each bounded to its own local scroll. A turn that hit
an error says so compactly on the collapsed row instead of only inside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three surfaces measured the window three ways at once. A bootstrap
script overwrote `--app-height` with a snapshot of `innerHeight` as an
inline style, so the `100dvh` the token resolves to could never win;
meanwhile the sidebar, the workspace and the automations page each
hardcoded `100dvh` and bypassed the token entirely. While a mobile
browser animates its chrome the two disagree, and the rail ends up a
different height from the pane beside it.

The token is now the only answer, and the mobile sheet stops fighting
it: capping every `div` at `100vw` re-measured nested panes against the
window, and a 44px floor keyed to window width — not pointer type —
inflated every control in a narrow desktop window, which is why the
compact rows above needed `!important` to survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pt alone

Forcing a row past the page limit matched focused or running in one
pass, so a background run could take the slot the focused session
needed and leave it unrendered. Focused wins now; a running row is only
promoted when nothing is focused.

Entering the archive prompt also closes the options menu explicitly
rather than relying on the menu's own dismiss ordering, so cancelling
lands back on the plain row from every entry point.

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>
(cherry picked from commit 00f62e6)
loadInitialFromStorage seeds messages from the localStorage snapshot before
chat-pane-hooks decides whether to run the canonical replay, and that guard
only asked whether messages existed. The snapshot is lossy by design — capped
at 200 messages and 512KB, carrying no history cursor — so it satisfied the
guard, the replay never ran, and a reloaded session was stuck showing whatever
fit in the cache with no "Load earlier" affordance.

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 and the replay now runs behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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, so with tool-heavy transcripts every cached
session was lost roughly every tenth write — the cache that exists to make
reopening instant was empty exactly when the most sessions were open.

Drop the least-recently-updated entries one at a time until the write fits.
The ordering already existed inside evictStaleSessions, so it moves into
oldestFirst and both callers share it rather than sorting twice. The cache
stays lossy and evictable and still never surfaces as an error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ntries

The per-run merge cache keeps a settled turn's object identity stable so
MemoMessage does not re-render the whole transcript on every streamed token.
Capping it at 512 entries and clearing it wholesale inverted that on any
conversation with more runs than the cap: each frame missed on entries it had
just evicted, so every settled turn got a new identity per token. An LRU bound
behaves the same way, because a sequential walk longer than the cache evicts
exactly the entries it is about to ask for.

Drop what the pass did not see instead. Entries now leave because their run
left the transcript, never because a counter filled.

Ported in place rather than extracting the derivation to its own module: the
extraction in the source commit exists to give the test and the bench a shared
target, and this wave adds neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
patchAssistantMessage copied session.messages on every patched event. The live
path needs that copy — React diffs the array identity to decide what
re-renders, so writing through it would make a streamed delta invisible — but
canonical replay does not. foldSessionEvents builds a private session from an
empty array and only the final result escapes, so every intermediate copy was
garbage the moment the next event landed.

Thread the ctx.replay flag that already exists into the patch. Outside the fold
ctx.replay is undefined, which selects the copying default, so the live event
path is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runtime-alive fallback already spreads the session before it overrides
status and error, and nothing in that literal touches hydratedFromCache, so the
flag was preserved verbatim without the explicit line. All it did was normalize
undefined to false, and the sole consumer negates the value, so the two are
indistinguishable; the flag is read-derived and never persisted or sent on the
wire.

The seedCached mark stays. That path seeds a lossy snapshot and, when cwd is
unset, returns before any replay runs, so without the mark the guard would read
the placeholder as the real transcript and suppress the replay for good.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ract

Sessions are threads. shared/agent/thread.ts now defines that contract once —
summary, parent relation, opaque window cursor, list and window requests, and
archive state — with Effect Schema for the two shapes that cross the boundary,
matching the session-goal precedent of deriving the type from the schema.

session-summary.ts becomes a legacy alias so every existing SessionSummary and
AggregatedSession consumer keeps resolving to the same structural type. No
field, name, or wire shape changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Discovery, windowed reads, parent relations, archive and parent linking were
reachable from four places: the session HTTP handlers imported the rollout
store and the metadata store directly, subagents imported the metadata store,
and the package index re-exported the store. Cross-project aggregation and the
archived-only backfill lived in the handler, so the durable read model was
partly defined by HTTP code.

thread-repository is now the sole logical API over both stores. sessions-store
and session-metadata-store become its implementation details; the only direct
imports left are findSessionFile and configuredPiSessionDir, which resolve PI
rollout paths and stay a compatibility boundary.

The handlers keep their routes, query parsing, status codes and response
shapes, and are now adapters over the repository. Archive stays metadata-only,
parent links stay durable, and no identifier is remapped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drops the Chatterbox voice UI (modal, creator, preview, reference, runtime and
store), the bundled chatterbox-voice plugin resources, and every speech client
in lib/api/speech.ts except transcribeAudio: status, install, install cancel,
runtime stop, voice list/create/delete and the synthesis preview.

The agent-runtime plugin host capability goes with it — the "speech"/
"synthesize" host binding existed only so the Chatterbox plugin could reach the
controller, so PluginHostCapability, its schema and loadHostCapability are gone
and plugins-section no longer branches on it.

Proxy wiring loses only the TTS-specific entries: the v1/audio/voices body
limit and the install, install/cancel, speech, voices and runtime/stop POST
timeouts. The v1/audio/transcriptions 101 MB limit stays.

Dictation is unchanged: use-composer-dictation still records through
MediaRecorder and posts to createSpeechApi().transcribeAudio.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes the speech route, its 64 KB request schema, the Chatterbox synthesis
branch and the local TTS model path, and unregisters the speech module and the
SpeechService from the app context.

With TTS gone the audio module has a single service again, so the generic
model-path resolver collapses back into resolveSttModelPath, ensureServiceLease
and audioErrorResponse drop their service discriminator, and the 413 branch no
longer has to choose between two limits.

POST /v1/audio/transcriptions is untouched: same route, same 100 MB upload
cap, same lease check, same transcribeAudio from services/stt.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing has referenced these since the speech route was unregistered: the
Chatterbox contract, the speech module (routes, service, runtime, worker
client, the Python worker, storage, voice store, voice vault and reference
audio) and the local TTS integration in services/tts.ts.

The GPU lease registry goes with them. It existed to arbitrate one device
between the model and the speech worker, and the speech service was its only
caller, so the pinned "speech" record and the claim/replace/release/snapshot
surface have no remaining producer. resolveRecipeGpuUuids and
GpuVisibilityResolution stay — compute/bridge still resolves recipe GPU
selectors through them — and the GPU lease shown in the UI is unaffected
because metrics-collector derives its holder from the running inference
process, not from this registry.

Speech-to-text is untouched: services/stt.ts and POST
/v1/audio/transcriptions both remain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…contract owns

session-metadata-store declared its own SessionArchiveState and
SessionSubagentLink, both structurally identical to ThreadArchiveState and
ParentRelation in shared/agent/thread. Neither had a consumer outside the file,
so they were a second declaration of shapes the canonical contract already
owns. ArchivedSessionMetadata, SessionListMetadata, sessionSubagentLink and
setSessionArchived now reference the canonical types directly.

sessions-store loses its SessionSummary re-export. Nothing imported the type
through the store, and the file keeps its own import from
shared/agent/session-summary for internal use.

Type annotations only. The stored shape is untouched: agent-session-metadata
keeps its version, its sessions map and every field name, the archive cascade
and lock behaviour are unchanged, and no call returns a different value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The replay guard added the tab id to replayedRef before calling
loadAndReplay, so the latch recorded that an attempt had started rather than
that a transcript had arrived. When the canonical read failed the engine put
the session back to idle with its seeded history intact, which leaves it
retry-eligible by every state test the guard makes — but the tab was already
latched, so it never tried again. The runtime-alive branch had the same shape:
it marks the session running, and once the runtime settled back to idle the
tab was still latched.

Latch on the outcome instead. A successful replay is the only thing that
writes hydratedFromCache: false, so that flag now ends the automatic path
outright, including for a session that legitimately replays to zero messages —
which a purely message-count test would have retried forever.

Failures schedule their own retry. The failing update reruns the guard while
the backoff is still open, and that rerun arms a timer rather than returning,
because after an idle failure against a dead runtime nothing further mutates
the session and an opportunistic retry would wait for a refocus that may never
come. The timer bumps a counter that is itself a dependency, so the guard
reruns from fresh props and re-checks every eligibility test before it spends
an attempt. It is returned through the subscription cleanup, so a tab switch,
a session change or an unmount cancels it, and the deadline lives in the ref,
so repeated reruns cannot stretch or shorten the wait.

Three attempts per tab, three seconds apart, then the guard stops arming
timers. Overlap is impossible from two directions: the session reads loading
for the whole flight, so the guard exits before scheduling, and inFlightReplays
still rejects a second replay for the same session. The engine is untouched, so
cache seeding, cursor hydration and reconnect are unchanged, and the manual
loadAndReplay handle still ignores the budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The raw route read the whole file into memory and handed back one buffer, so a
video was capped by the 64 MB guard and, below it, still had to be fully
resident before the first byte reached the client. A media element cannot seek
against that either, because the route advertised no range support.

fs-store stops reading bytes. resolveReadableFile replaces readFileBytes and
returns the contained path with its size and mtime, leaving the read to the
route, which now pipes createReadStream through Readable.toWeb. The size cap
goes with the buffering it existed to bound. Containment is unchanged: the same
workspace-root resolution, the same realpath check on root and target, the same
explicit prefix assertion and the same regular-file check, all still ahead of
any read.

Ranges are single-range only. A parsable satisfiable range answers 206 with
content-range and a content-length covering just that slice; no range header
answers 200 with the whole file. Anything else — malformed, multi-range,
reversed, past the end, a bare "bytes=-", a zero suffix, or any range against
an empty file — answers a bodyless 416 carrying bytes */size. An empty file
with no range header stays a 200 with no body rather than a 416.

Audio and video extensions join the inline map so the browser gets a real media
type. SVG deliberately does not: it stays an opaque attachment, so a repo's own
markup still cannot execute as a same-origin document. Every existing header
survives and accept-ranges plus a same-origin CORP are added alongside them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An assistant that produced a screenshot or a clip could only name the file. The
path rendered as a link into the Files panel, so seeing the result meant leaving
the transcript.

assistant-media owns the classification. It reads an extension off a reference
and answers image, video or audio, and it refuses anything carrying an http,
https, data or blob scheme, so a remote URL is never fetched or rewritten — only
paths that resolve inside the workspace become sources. Those sources point at
the authenticated same-origin raw route, which already enforces the workspace
containment and now streams with range support, so a video seeks instead of
buffering whole. A remark pass promotes bare paths in prose to links, and
markdown links, inline code and image syntax classify through the same helper,
so the three ways a model names a file behave identically.

Rendering degrades rather than breaking. A reference that will not resolve to a
contained file falls back to the ordinary clickable file link, and an element
that fails to load swaps back to that same link, so a moved or deleted file
still leaves something that opens the Files panel.

SVG is deliberately absent from every extension set. It classifies as nothing,
so it renders as a file reference rather than inline markup, and HTML and
unknown types keep the opaque-download behaviour the raw route already gives
them.

cwd threads narrowly from ChatPane through Timeline, MessageView and the block
router to the markdown renderer, and joins MemoMessage's comparator so a session
switch repaints media. The transcript-scoped merge cache, the 48rem thread
geometry, chat typography and scroll ownership are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ges outside

Litter appends to the same Pi rollout Local Studio has open. The SessionManager
that Studio started holds its own leaf, so those turns stayed invisible until
the session was reopened by hand: the file on disk had moved on while the
in-memory session had not.

refreshExternalChanges compares the rollout's size against the size recorded
when the runtime started. A file that has not grown short-circuits, and a file
that has grown is reopened to compare leaves, so an append Studio itself made
only re-seeds the recorded size. A genuinely different leaf clears the runtime
fingerprint and restarts through the existing ensureStarted with the same
modelId, cwd, piSessionId and start options, which is what makes the reopened
transcript project through the ordinary path rather than a second one.

It refuses to act on a busy session. An active prompt, a stream, a compaction
or a pending queue all return early, so a restart can never land mid-turn or
drop queued messages, and queue delivery keeps its once-and-in-order guarantee.

Wiring is additive. handleRuntimeStatus and handleRuntimeSessions await the
refresh before they read status; both keep their routes, shapes and behaviour
and only become async. The turn path deliberately does not call it: it shares
findSessionForLookup with the status path, and ensureStarted resets eventSeq
and the event log, so refreshing there could race a submission. Restarting does
reset the sequence, which is why it is confined to the status and snapshot
paths the client already treats as non-monotonic through adoptExternalCursor;
receivedSeq and committedSeq, reconnect anchors, Session.id versus piSessionId,
archive-only semantics and the Thread repository are all untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-run id embedded Date.now(), so every scheduled tick minted a fresh
PiAgentSession entry in the manager map that nothing ever reclaimed. The
running-set guard in runAutomationNow() already serializes runs of the same
automation, so a stable `automation:${id}` key cannot collide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
refreshExternalChanges runs every guard synchronously and only awaits at the
restart, so a second caller can enter exactly once the first has cleared the
runtime fingerprint and yielded. It then sees the pre-restart world: the old
runtime is still attached, the busy guards still read idle, and the recorded
file size is still the old one because the restart path updates it only when
ensureStarted finishes. So it clears the fingerprint again and starts a second
ensureStarted, and each of those stops the session and resets eventSeq and the
event log.

Two concurrent requests are enough to reach it. The status poll and the
sessions snapshot both await the refresh on the same event loop, and two status
polls for one session overlap the moment a poll is slow because it is already
restarting.

Hold the in-flight promise and hand it to any caller that arrives while it is
running, clearing it when it settles. The field is per session, so a snapshot
refreshing many sessions still runs them concurrently. Guards, cursors and
queue handling are unchanged; the body moved behind the dedupe untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An automation had no way to say which conversation it belongs to, so every
scheduled run was global by construction. The canonical contract now carries a
discriminated target: global, or a thread that names the stable threadId the run
belongs to plus the piSessionId it was captured from, so provenance survives
even when the runtime later resumes the rollout under a different session.

Decoding stays lossless. A record written before this commit has no target field
and normalizes to global, which is what those records already meant, and the run
history only gains a target when the stored run actually recorded one rather
than backfilling a claim about history it never made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The refresh compared only byte size, and an unchanged size returned before the
leaf comparison ran. A rollout that is replaced rather than appended to can
land on exactly the same length — an atomic write over the old path, a
compaction that happens to break even, an equal-length rewrite — and every one
of those was silently treated as no change, so the session kept serving the
transcript it already had.

Stamp size with mtimeMs instead. Both come from the stat the refresh already
performed, so the check costs nothing more and a replacement that keeps its
length still moves the stamp and reaches the leaf comparison, which decides
whether a restart is warranted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scheduler owned everything: the tick, the due comparison, the overlap lock,
model resolution, starting a runtime session, prompting it and shaping the run
record. Splitting execution out leaves the scheduler with the one job its name
promises — when a run is due and that a second run of the same id never starts —
and puts the run itself behind runAutomation(), which takes an automation and
returns the record of what happened without touching the store.

The executor is where the target now decides how a run starts. A global target
keeps the fresh-session behaviour it always had. A thread target resumes the
conversation's own rollout, and refuses to run when that rollout is not on disk
rather than silently starting a detached conversation the automation would then
be attached to in name only. The stable automation:${id} runtime key stays, so
a resumed thread still runs under the automation's own session rather than the
one the UI may have open on the same rollout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Create and patch hand-rolled every field check, so a target would have reached
the store as whatever the client sent and been silently coerced to global on the
way to disk. Both routes now decode it through the canonical Effect Schema and
answer 400 with the accepted shape, which keeps one definition of what a target
is and makes a malformed thread target a refusal instead of a global automation
the caller never asked for.

An absent target stays absent, so existing clients keep creating and patching
automations exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread frontend/src/features/agent/fs-store.ts Fixed
@0xSero

0xSero commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Installed Dev acceptance at 5f09b8f

  • Full npm run check passed on the exact pushed commit.
  • Built and signed with npm --prefix frontend run desktop:dist:dev; afterPack verified the embedded frontend and agent runtime.
  • Installed through scripts/install-desktop-app.sh dev, which quit the previous Dev process and replaced /Applications/Local Studio Dev.app.
  • Relaunched installed app as PID 99835 with agent runtime PID 955.
  • Installed manifest contains standalone /models, /integrations, /plugins, /settings, and /agent/automations routes; the legacy /models redirect is absent.
  • Computer Use acceptance: Models direct sidebar navigation; Models marketplace; Plugins catalog and plugin detail; Scheduled tasks list/detail navigation; Configure overview; all seven Settings destinations and canonical headings; fullscreen-width layout.
  • Independent release review: GO, no P0/P1 route or visual blockers.

The PR remains draft and unmerged. Local screenshot evidence is under /Users/sero/.codex/visualizations/2026/08/12/019ff812-dcd8-7842-8107-5dc9a39e6e07/local-studio-parity/10-final-models.png through 14-final-settings.png.

@0xSero

0xSero commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Settings centering follow-up landed in 759071a. Root cause was the shared 46rem Settings detail wrapper being left-pinned inside its flexing detail pane; the wrapper now centers via the same mx-auto geometry used by the Codex donor. Full npm run check passed, Local Studio Dev was rebuilt/reinstalled/restarted from this exact commit, and native installed-app validation confirms the General detail rail is centered. All non-frontend CI jobs pass; frontend remains limited to the previously documented three stale legacy redirect expectations.

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.

2 participants