feat(agent): match Codex threads and scheduled tasks - #407
Conversation
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>
|
Installed Dev acceptance at 5f09b8f
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. |
|
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. |
Summary
Rebuilds the Local Studio agent workspace around Codex tasks while removing competing session and voice implementations.
Architecture and safety
Measured change
Validation
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