# Release v1.53.0

Released: 2026-05-12

**Full Diff**: https://github.com/atomantic/PortOS/compare/v1.52.0...v1.53.0


## Tests

- **God-file test coverage for `cos.js` + `agentLifecycle.js`.** Added `server/services/cos.test.js` (18 tests) pinning `evaluateTasks` priority ordering (Priority 0 onDemand → 1 user → 2 autoSystem → 3 mission → 4 idle, with mission/idle gated by `hasPendingUserTasks` and idle further fenced behind `spawned === 0` / `tasksToSpawn.length === 0`) and `dequeueNextTask` capacity guards (global `maxConcurrentAgents` early-return when saturated or over-saturated, per-project `maxConcurrentAgentsPerProject` cap counting both pre-existing runners and in-batch spawns, `_self` sentinel for app-less PortOS tasks, fallback to global cap when per-project is unset). Extended `server/services/agentLifecycle.test.js` (+15 tests: 11 active + 4 skipped) covering `spawnAgentForTask` cleanupOnError paths (no-provider, no-fallback, git-conflict, updateTask-null) and `handleAgentCompletion` error-recovery regression pins (throws from `completeAgent`, `updateTask`, `completeAgentRun`, `processAgentCompletion` all leak the `runnerAgents` Map entry because none of those steps are wrapped in try/catch and the terminal `runnerAgents.delete(agentId)` is unconditional at the function tail). Tests follow the inline-function-copy pattern from `subAgentSpawner.test.js` — pure logic replicas exercised with test fixtures, plus source-level regression guards that pin priority order, the `availableSlots <= 0` short-circuit, and the runner-cleanup tail placement. 4 skipped tests are TODO markers for the PLAN.md backlog item "Widen `spawningTasks` try/finally in `agentLifecycle.js#spawnAgentForTask`" — they flip green once that fix lands.

## Added

- **World Builder → Pipeline bible bridge.** Worlds now carry three narrative-bible fields — `logline`, `premise`, and `styleNotes` — alongside the existing image-prompt template. The World Builder editor surfaces them in a new "Story bible" section, and the LLM "Expand starter → variations" pass now generates them in the same call (only overwriting the user's prose when the model actually produced new copy, so re-running expand to refresh image categories doesn't clobber hand-edited bible text). The Pipeline → New Series form gains a world dropdown as the first field; picking a world auto-fills logline / premise / style notes from that world (only into empty form fields, so a user who's already started typing isn't surprised). `worldId` + `premise` + `styleNotes` now ride the create-series payload alongside `logline`, populating the series bible in one click. Backed by new caps (`LOGLINE_MAX` / `PREMISE_MAX` / `STYLE_NOTES_MAX`) on `server/services/worldBuilder.js`, shared client-side via `WORLD_LOGLINE_MAX` / `WORLD_PREMISE_MAX` / `WORLD_STYLE_NOTES_MAX` exports from `apiWorldBuilder.js`. Test coverage in `server/services/worldBuilder.test.js` (persist + patch + trim-to-max) and `server/services/worldBuilderExpand.test.js` (asserts the EXPANSION_PROMPT keeps the bible contract).

- **Voice agent — read the page aloud.** A new `ui_read` voice tool extracts the visible text of the current page (main content area, excluding nav rails and the voice widget) and returns it verbatim. Ask "what does this say?", "read this aloud", or "what's on the page?" and the assistant speaks the content back. Text is captured from the same client-side index that already powers `ui_click` / `ui_fill`, so no extra round-trip and no new permissions. Output is capped at ~8 KB on a word boundary; longer pages get a graceful ellipsis tail.

- **Voice agent — destructive-action confirmation.** When the voice agent is about to click a button whose label matches Delete / Remove / Discard / Reset / Clear, the click is paused and the assistant asks for spoken confirmation ("say yes or confirm to delete, or cancel to skip"). Your next spoken word resolves the prompt: *yes / confirm / do it / go ahead* fires the click, *no / cancel / stop / never mind* drops the pending action, and anything else (a new request like "what time is it?") quietly discards the pending click and answers the new question. Pending confirmations expire after one minute so a forgotten "yes" minutes later can't re-fire a stale destructive click.

- **Voice agent — proactive Chief-of-Staff speech.** The CoS can now speak first — alerts, briefings, reminders — without waiting for you to ask. Disabled by default; turn it on in Settings → Voice. A quiet-hours window (also off by default, defaults 22:00 → 07:00 in your local time) suppresses proactive speech during sleep hours, and barge-in works the same as a normal turn — start talking and the in-flight proactive line stops immediately. New `POST /api/voice/speak` endpoint lets internal subsystems (and integration tests) trigger a proactive line; suppressed lines return `{ ok: false, reason }` so callers can react.

- **Favorite + note any generated image or video.** Every MediaCard now has a star toggle in the corner; clicking the tile opens the lightbox where a single editable note (max 2000 chars, 500ms debounced save) lives next to the prompt. Favorites and notes are surfaced across all four gallery surfaces — Media History, Image Gen, Video Gen, and Media Collection Detail — and a "Favorites" filter chip on each scopes the grid to starred items only. Press `s` while a lightbox is open to toggle favorite. Annotations persist to a new sidecar `data/media-annotations.json` keyed by `<kind>:<ref>` (same scheme as collections) so they survive when the underlying job records get pruned. Empty annotations (`!starred && !note`) get removed from disk on write to keep the file lean. Backed by `server/services/mediaAnnotations.js` + `server/routes/mediaAnnotations.js` (GET / PATCH with prune-on-empty + 2000-char note cap), the shared `useMediaAnnotations()` hook with optimistic-update + revert, and `MediaCard` / `MediaLightbox` prop extensions (`starred`, `hasNote`, `onToggleStar`, `annotation`, `onAnnotationChange`).

- **World Builder — per-element Render buttons.** Each composite board, each category item (variation), and each category header now has its own inline Render button — no need to render the whole batch when you just want to iterate on one image. Per-board and per-variation buttons render exactly one prompt; the per-category header button renders every variation in that category. All three reuse the page's existing render-options panel (backend, model, resolution, batch-per-prompt) and route through the same job-queue + collection-tagging path as the full batch render, so toast + run history behave identically. Buttons disable themselves when the world isn't saved, no backend is configured, or a render is already in flight; per-category disables when the category has no variations yet.
- **World Builder — Refine prompts modal.** Once a world has a starter idea, click *Refine prompts* to describe a tweak in plain English ("more painterly", "lean harder into the moebius linework", "drop the cyberpunk neon") and the LLM rewrites the starter, style, and negative prompts together, with a one-line rationale shown alongside each. Each rewritten field is editable inline before *Apply*; applying auto-persists the result back to a saved world (mirrors the *Expand* flow's auto-save) so subsequent renders/expansions pick up the refinement immediately. Backed by `POST /api/world-builder/refine-prompts` + `server/services/worldBuilderRefine.js` reusing the same stage-runner plumbing the Expand pass uses.
- **Refine Prompt action in the Media Lightbox.** Open any past image or video render and click *Refine Prompt* to describe what you'd like to change in plain English ("more painterly", "switch to watercolor", "drop the cyberpunk neon"). PortOS hands the original prompt + your feedback to any enabled AI provider — API (OpenAI, Anthropic, LM Studio) or CLI (Codex, Claude Code, Gemini CLI) — and shows the rewritten positive and negative prompts as editable text plus a one-line rationale. Tweak the new prompt if you want, then *Queue Render* to make a new render that inherits the original's model, resolution, LoRAs, seed, and other settings.
- **Pipeline — Arc canvas UI.** PipelineSeries's right-side canvas is now a structural Arc → Season → Episode tree. Top of the canvas shows the series arc card (logline, themes, collapsible summary + protagonist arc, edit-in-place fields). One-click `Generate arc` runs the Phase 3 LLM pass and persists `series.arc` + `series.seasons[]`; `Verify arc` surfaces continuity findings inline with severity badges. Each season expands/collapses, shows `N / target episodes` count, and exposes inline edit (number / title / logline / synopsis / ending hook / status / episode-count target). Episodes render as compact rows with delete, hover-revealed season picker (move between seasons), and direct deep-link into the issue editor. `Generate episodes (LLM)` on an empty season runs the per-season-episodes pass and creates one issue per beat with `seasonId` + `arcPosition` pre-filled; un-grouped issues stay visible in a dedicated bottom panel. Replaces the flat issue-card grid from Phase 1; backed by the schema + endpoints from Phases 2 + 3. Two-click-arm replace guard on arc regeneration and per-season delete. Phase 4 of the Story Arc Planning initiative.
- **Pipeline — LLM-driven series-arc planning.** Three new staged-LLM passes that plan and verify a multi-season series: (1) `POST /api/pipeline/series/:id/arc/generate` proposes the top-level arc — series-level logline, summary, themes, protagonist arc — plus a 2–5 season breakdown with per-season loglines, ending hooks, and episode-count targets, default `model: 'heavy'`. (2) `POST /api/pipeline/series/:id/seasons/:seasonId/episodes/generate` proposes the episode-by-episode breakdown for one season, taking the whole-arc protagonist trajectory and prior seasons' synopses as continuity context. (3) `POST /api/pipeline/series/:id/arc/verify` runs a cross-season continuity pass that surfaces character contradictions, dropped subplots, episode-count/arc-weight mismatch, unresolved finale hooks, arc-role imbalance, and theme drift as actionable `{ severity, location, problem, suggestion }` issues. All three calls accept `{ providerOverride, modelOverride }` and routes 1 + 2 also accept `commit: true` to persist the proposal in one shot (default returns a preview the UI can confirm before writing). Backed by `server/services/pipeline/arcPlanner.js` (~280 LOC) on top of the shared `stageRunner.js` + new stage prompts in `data.sample/prompts/stages/pipeline-arc-{overview,verify}.md` + `pipeline-season-episodes.md`, all three reusing the `_partials/bible-deference.md` block for character/setting consistency. Phase 3 of the Story Arc Planning initiative — no UI yet (Phase 4 builds the arc canvas on top of these endpoints).
- **Pipeline — series-arc + season schema.** Series records gain two new optional fields: `series.arc` (logline, summary, themes, protagonistArc, status) for the overall multi-season story spine, and `series.seasons[]` for the ordered season/volume list. Issues gain optional `seasonId` + `arcPosition` pointers so an issue can be slotted into a specific season at a specific ordinal. Backed by a new `server/lib/storyArc.js` (canonical shapes + sanitizers, sibling to `storyBible.js`) and a new `server/services/pipeline/seasons.js` service that handles delete-with-reassign so removing a season cleanly re-points or un-groups child issues. Routes: `GET / POST /series/:id/seasons`, `PATCH / DELETE /series/:id/seasons/:seasonId`. Pure additive — existing series files round-trip unchanged. Phase 2 of the Story Arc Planning initiative; no UI yet (Phase 4 builds the arc canvas on top of this).

- **Prompt template partials** — PortOS prompts now support Mustache-style `{{> partial-name }}` includes. Files in `data/prompts/_partials/` get inlined into stage templates at render time (recursive, with cycle detection + loud missing-partial errors). First two partials cover the duplicated bible-deference preamble and the scenes-JSON output contract that were copy-pasted across `writers-room-script.md` and `pipeline-extract-scenes.md`. Both prompts now reference the shared partials — future tweaks to "defer to canonical character names" or "this is the scenes[] JSON shape" land in one place. Step 7 of the Writers Room ↔ Pipeline DRY unification.
- **Writers Room ↔ Pipeline bridge.** A "Promote to pipeline" action on a Writers Room work creates a Pipeline series + first issue in one call, carrying over the active draft prose, all three bibles (characters/settings/objects), and the latest script-analysis scenes as fully-populated storyboards. The two systems record each other's ids so the menu flips to "Open in pipeline" on subsequent visits, and the Pipeline series page surfaces a "Writers Room" badge linking back. Idempotent — re-promoting a linked work returns the existing pair instead of duplicating. Backed by `server/services/writersRoom/promoteToPipeline.js` and `POST /api/writers-room/works/:id/promote-to-pipeline`. Step 6 of the Writers Room ↔ Pipeline DRY unification.
- **Pipeline page** under Create — a series-production workflow that chains a rough idea seed through prose → comic-book script + TV teleplay (parallel) → comic pages + storyboards (image gen) → episode video. Each Series carries a shared bible (logline, premise, characters, optional World Builder ref, style notes) that every issue/episode inherits into its stage prompts so identity stays consistent across the run. The "Auto-run text" button kicks off the LLM chain end-to-end with SSE progress; the visual stages (comic pages, storyboards) hand off to the existing image-gen queue.
- **Pipeline episode video** now wired end-to-end to Creative Director. The Episode Video stage on a Pipeline issue creates a CD project from the storyboards scenes (series style notes auto-prepended, i2v continuation from scene 2 onward, audio disabled, scenes auto-accepted — no LLM evaluator round-trip since the human already vetted storyboards), persists the `cdProjectId` on the stage, polls render progress, and renders the final stitched mp4 inline when complete. A second "Run everything (incl. video)" button on the issue page extends auto-run past the text stages — text chain → episode-video kickoff in one click.

- **Pipeline — live per-panel/scene thumbnails during diffusion.** Comic Pages and Storyboards stages now show the in-progress preview inline as each panel/scene renders, then the final image (or `<video>` with poster for video kind) once complete. A new `useMediaJobProgress` hook subscribes to socket `image-gen:*` / `video-gen:*` events filtered by jobId and hydrates from `GET /api/media-jobs/:id` on mount. Canceled jobs render an obvious terminal state (Ban icon) instead of falling through to a stuck running spinner. Speculative `getMediaJob` lookups for expired job ids return `severity: 'warning'` 404s so the global error toast stays quiet.

- **Pipeline — single-scene storyboard video render.** New "Scene video" button on every storyboard scene enqueues a single t2v clip via `mediaJobQueue` (`POST /api/pipeline/issues/:id/stages/storyboards/scenes/:index/video`). Aspect-ratio dims pull from the shared `ASPECT_PRESETS` table so the picker matches Episode Video. The scene record persists a `sceneVideoJobId`; the inline `MediaJobThumb` ties to the new field and surfaces live progress + final video. `modelId` is validated against `getVideoModels()` before enqueue so unknown ids fail-fast with `PIPELINE_UNKNOWN_VIDEO_MODEL` instead of leaving a doomed entry in the queue.

- **Pipeline — voice stage navigation.** Three new voice tools (`pipeline_next_stage`, `pipeline_prev_stage`, `pipeline_open_stage`) parse the current `/pipeline/issues/:id/:stage` route and push a navigate side effect for stage transitions. New `pipeline` group in `TOOL_GROUPS` + `GROUP_INTENT` regex routes phrasings like "next stage", "back to prose", "open storyboards", "go to teleplay", "comic pages" (and singular variants), "scenes", "episode video". Stage list imported from `issues.js#STAGE_IDS` to avoid drift; aliases table handles spoken variants. Canonical lookup is case-insensitive so "Prose" with capital P resolves.

- **Pipeline — dynamic sidebar.** Layout fetches `GET /api/pipeline/issues/recent?limit=10` on mount + on focus (30s debounce, signature-guard against no-op re-renders) and renders recent issues as one-level grandchildren under Create > Pipeline. The signature fingerprint includes display fields (`number`, `title`, `seriesName`) so a series rename refreshes child labels even though the issue's `updatedAt` doesn't bump. `activePathPrefix` keeps the row highlighted across stage tabs. Backed by a new `listRecentIssues()` service that sorts the full issue set by `updatedAt` desc before slicing — so as the dataset grows past `ISSUES_PER_RESPONSE_MAX` (1000) the sidebar still surfaces the latest items.

- **Pipeline — AI prompt refine for comic panels + storyboard scenes.** "AI: refine" button on every comic panel and storyboard scene routes through `runStagedLLM` (JSON-out) against two new templates (`pipeline-comic-panel-image-prompt.md`, `pipeline-storyboard-image-prompt.md`) registered in `stage-config.json`. The model rewrites just the panel/scene description with surrounding bible + style-notes context loaded by a shared slim `loadRefineContext`. Concurrent refines on different rows are serialized via the single in-flight tracker — disabling all refine buttons during any in-flight one — to prevent issues.json read-modify-write races. Same serialization applies to per-scene video render clicks.
- Codex image gen: hi-res resolution presets (1024×1536, 1536×1024, 1536×1536) — gpt-image-2's native hi-res tier. Picking any ≥1536 size automatically appends a `(high quality)` hint to the codex prompt so the model renders at full fidelity instead of its 1024 default.
- Image Gen batch mode: a `× N` count input next to the Generate button (async modes only — local + codex) queues N renders of the same prompt+resolution in a single submit. The first job streams its progress live; the remaining N-1 land in the server queue and surface as `+N queued`. Each job gets a fresh random seed by default so the batch produces variations, not duplicates.
- Media Lightbox full-screen mode (`F` key or top-right toggle): drops the modal's rounding and padding and lets the image fill the viewport. Swipe left/right (or arrow keys) navigates between gallery items; tap reveals the settings as a slide-in drawer. Layered Escape closes drawer → exits full-screen → closes the lightbox. The Seed row in the settings pane is now copyable for easy reuse in a remix.
- Media Lightbox surfaces a `Codex session` row for gpt-image-2 renders (the session-id from the codex CLI banner, written to the image's sidecar metadata). Closest analogue to a seed for codex output — copyable for traceability. The Seed row now reads `n/a (gpt-image-2)` for codex images instead of hiding, so it's clear why no seed is present.

## Changed

- **Backup snapshots are namespaced by machine hostname.** Snapshots now write to `<destPath>/snapshots/<hostname>/<snapshotId>` instead of the prior flat `<destPath>/snapshots/<snapshotId>` layout, so a single shared destination (e.g. an iCloud Drive folder) can host backups from multiple machines without snapshot-ID collisions. The hostname is lowercased and stripped to `[\w.\-]` for filesystem safety, captured once at module load. `listSnapshots` and `restoreSnapshot` both scope to the current machine's hostname dir, and the containment guard in `restoreSnapshot` was re-rooted to match. Existing flat-layout snapshots from earlier versions stop appearing in the UI until moved into a hostname subfolder — backward-incompatible on the disk layout but no migration is shipped because the user-base is small and the snapshots are recoverable by hand.

- **Organize from the lightbox + from inside a collection.** `MediaLightbox` footer now hosts an Add-to-collection (folder+) button alongside Download — pick a collection (or create one) without closing the larger preview. `MediaCollectionDetail` cards no longer suppress the same menu, so you can move/copy an item between collections from inside any collection view. `AddToCollectionMenu` gained a `size` prop (`sm` default for the card action row, `md` for the lightbox footer) so both surfaces share one component without duplicate styling, and long collection names in the picker now wrap instead of getting clipped with ellipsis. `browser/data/` is now gitignored (CDP profile cache shouldn't be tracked).

- **Bulk actions on a collection's contents.** A new "Select" button on `/media/collections/:id` flips the grid into selection mode — click cards to add to the selection, then act on the lot via a sticky toolbar: **Star** / **Unstar** all selected, **Move…** or **Copy…** to a target collection (pick from a popover anchored to the button, or create a new collection inline), **Remove** from the current collection without deleting the underlying file. Move/Copy guarantees data integrity by tracking which items actually placed in the target before removing them from the source. Sequential per-item HTTP because the server has no per-collection lock; PLAN.md tracks a bulk endpoint for the inevitable 50+-item case.

- **Implicit "Unsorted" collection.** Every image or video that isn't filed in at least one real collection now shows up under a synthetic "Unsorted" entry pinned at the top of `/media/collections` (Inbox icon, accent border, count + thumbnail). Open it to browse the unfiled media at `/media/collections/unsorted` — same grid, same lightbox, same bulk-action bar (Move… is relabeled "File…" since there's no source-side remove). Cover / rename / Copy / Remove are hidden because they're nonsensical for a synthetic source. After filing items into a real collection from here, the view re-derives and the now-filed items disappear automatically. Implementation is fully client-side — `client/src/lib/unsorted.js#buildUnsortedCollection` projects the union of `listImageGallery` + `listVideoHistory` minus every filed `<kind>:<ref>` key — so no new server route was needed.

- **Per-card trash in a collection now deletes the underlying file** (matches MediaHistory) instead of just unfiling it from the current collection. To unfile without deleting, the user can either uncheck the current collection via the folder+ menu, or select the item in select mode and use the bulk **Remove** action. Re-enabled on the Unsorted view too, since "delete the file" makes sense there even though "remove from collection" doesn't.

- **World Builder batch render reuses one collection per world.** Auto-generated collection names drop the ` — YYYY-MM-DD HH:MM` suffix: a batch render of "Echoes of the Choir" now files into `World: Echoes of the Choir` every time instead of minting a fresh date-stamped bucket. A user-supplied `collectionName` also flows through find-or-create (case-insensitive trimmed match), so repeat renders into a custom name append into the same collection. Backed by a new `findOrCreateCollectionByName()` helper in `server/services/mediaCollections.js`. One-shot migration at `data/migrations/001-merge-worldbuilder-collections.js` collapses legacy date-suffixed `World: <name> — <date>` collections into their base name (dedupes items by `kind:ref`, preserves the earliest `createdAt`, rewrites `world-builder.json` `runs[].collectionId` references). Run via `npm run migrations`; idempotent.
- **Clean → Light / Aggressive available beyond Image Gen.** The Eraser pill that strips render artifacts now appears in any image lightbox: Media History and Media Collection Detail both wire `onClean` into `MediaLightbox`. Cleaning from inside a collection automatically adds the cleaned file to that collection alongside the original, so the user can compare side-by-side before deciding which to keep. (Image Gen behaviour unchanged; Video Gen's lightbox only renders videos so it intentionally doesn't expose Clean.)
- **MediaCard thumbnails.** Removed the "IMAGE"/"VIDEO" badge overlay from the bottom-right of media cards — the icon and aspect already convey the kind, and the badge added visual noise on grid views.
- **Pipeline Series page — two-pane layout.** PipelineSeries (`/pipeline/series/:id`) drops its `max-w-5xl` cap and splits into a sticky left bible sidebar + right card-grid canvas at `lg+`. The bible inputs (name / format / logline / target issue count / premise / style notes / world / characters) live in the sidebar; issues/episodes render as a responsive card grid (1/2/3/4 cols across breakpoints) showing number, status badge, title (line-clamped), and `updated <timeAgo>`. Sidebar collapses to a 48px rail via a panel toggle (state persists in `localStorage` under `portos-pipeline-series-sidebar-collapsed`) so the canvas can use the full viewport when you're deep in episode work. Mobile (< lg) stays single-column with the sidebar reflowing above the canvas. Phase 1 of the Pipeline Story-Arc Planning initiative — pure UI refactor, no schema or API change; subsequent phases evolve the card grid into an Arc → Season → Episode tree.

- LTX-2.3 video model dropdown now groups the legacy notapalindrome runtimes (`ltx2_unified`, `ltx23_unified`, `ltx23_distilled_q4`) under a `Legacy` `<optgroup>`, with the dgrauet `ltx23_dgrauet_*` runtimes surfaced as the active picks at the top of the list. `defaultMacos` deliberately stays on `ltx23_distilled_q4` because the dgrauet runtime requires an opt-in venv install — fresh installs need a model that works out of the box. Applies to both Video Gen and Creative Director dropdowns. New Video Gen FFLF mode also gets a one-line keyframe-pair guidance hint ("use keyframes that share scene geometry — same camera, same subject; unrelated images produce a visual cut") to prevent the "looks like two stills" complaint.
- Writers Room bible CRUD (characters / settings / objects, one file per work) now collapses onto a single `createBibleStore(...)` factory in `server/lib/storyBible.js` — each domain file shrinks to ~30 LOC of factory configuration. -281 LOC across the three domain files. Pipeline bible routes share the writers-room zod schemas under generic names (`characterBibleCreateSchema`, etc.) instead of the prior `z.record(z.string(), z.any())` loose-record validation; the canonical `sanitizeBibleList` in `storyBible.js` remains the single source-of-truth for shape enforcement on both sides. Steps 2a + 2b of the Writers Room ↔ Pipeline DRY unification.
- Pipeline stage components (`ProseStage`, `TextStagePanel`, `EpisodeVideoStage`) now share a `useAsyncAction(fn)` hook for the common "setLoading(true) → await → catch+toast → setLoading(false)" pattern. `StoryboardsStage` deliberately stays on its existing pattern because its `savingIdx`/`extractingFrom` flags are keyed loading states, not booleans. Step 4c of the Writers Room ↔ Pipeline DRY unification.

- Pipeline `POST /api/pipeline/series/:id/extract-bible` accepts a `parallel: true` flag that fans out the three bible kinds concurrently (~3× wall-clock speedup on HTTP-API providers like OpenAI / Anthropic / LM Studio HTTP). Default stays sequential so CLI-only providers (codex, claude-code, gemini-cli) — which serialize at the provider session anyway — aren't penalized.
- Creative Director `GET /api/creative-director/:id` accepts a `?slim=1` query param that drops the unbounded `runs[]` history and the heavy treatment fields, returning only what a polling UI needs (`status`, `updatedAt`, per-scene `{ id, order, status }`, `finalVideoId`, `failureReason`). Pipeline EpisodeVideoStage's 4s poll now uses slim mode — keeps the UI responsive without pulling the entire render history on every tick.
- Pipeline Storyboards stage gains "From TV script" + "From prose" auto-fill buttons. Once the upstream stage has output, one click runs the new shared `extractScenes()` LLM pass (sluglines parsed from the teleplay, or paragraph-grain beats lifted from the prose) and replaces `stages.storyboards.scenes[]` with the result. A two-click-arm replace guard surfaces a warning toast before clobbering hand-curated scenes. Backed by `POST /api/pipeline/issues/:id/stages/storyboards/extract-scenes` and the new `server/lib/sceneExtractor.js`, which both Pipeline AND the Writers Room evaluator's `script` analysis now route through — deleting ~25 LOC of duplicated SHAPERS.script logic and giving the Pipeline a one-click path from "TV script ready" to "all storyboards primed for image gen." Step 5 of the Writers Room ↔ Pipeline DRY unification. Side fix: the issue PATCH schema's `stages` z.union order was swapped so visual-stage payloads (`scenes` / `pages` / `cdProjectId` / `videoPath`) no longer silently strip on the way through — the union evaluated the text-only schema first, hiding the visual fields.
- Pipeline ProseStage gains an "Extract bibles" button. Once you've drafted (or auto-run) the prose for an issue, one click fans out three LLM passes (characters / settings / objects) against the prose, sanitizes them through the canonical bible shape, and merges the result into the series-scoped bibles — fills only blank user-editable fields on existing entries (so your hand-edits survive) and inserts new entries with `source: 'ai'`. Backed by the new `POST /api/pipeline/series/:id/extract-bible` route + a single shared `server/lib/bibleExtractor.js` that the Writers Room analysis path also routes through; Writers Room's three bible SHAPERS (one per kind, ~75 LOC of duplicated field-trim) collapsed onto the canonical sanitizer. Step 4 of the Writers Room ↔ Pipeline DRY unification.
- Writers Room and Pipeline now share one staged-LLM runner (`server/lib/stageRunner.js`). The Writers Room evaluator's bespoke `callApiProvider` (direct fetch) + `callCliProvider` (direct spawn) + `buildCliInvocation` (codex/gemini-cli/claude-code per-CLI quirk handling) are gone; both the Writers Room analyses and the Pipeline text stages now go through `runner.js`'s `executeApiRun`/`executeCliRun`. Writers Room transcripts now persist to `data/runs/<runId>/` so analyses are replayable from the runs UI. Stage tier-name model resolution (`model: 'heavy'` etc) extends to the Pipeline for free. Behavioral note: per-stage temperature override (format=0.2, others=0.4) was dropped because the toolkit's API runner doesn't expose temperature — sampling now follows the provider's defaults. Step 3 of the Writers Room ↔ Pipeline DRY unification.
- Pipeline series gain settings + objects bibles (alongside the existing characters bible). All three writers-room bible kinds (characters, settings, objects) and the pipeline-side equivalent now share one canonical shape + extraction-merge implementation in `server/lib/storyBible.js`, deleting ~300 lines of near-identical per-kind code. Pipeline characters' legacy `description` field auto-migrates to `physicalDescription` on read so old series.json continues to load. The slugline plumbed in the previous unification step now activates: pipeline storyboard scenes that share a slugline with a setting bible entry get its description + palette + recurring details prepended to every render — visual continuity for free across an entire issue. Step 2 of the Writers Room ↔ Pipeline DRY unification.
- Pipeline visual stages (Storyboards, Episode Video) and Writers Room SceneCard now share one image-prompt composer (`server/lib/scenePrompt.js` + `client/src/lib/scenePrompt.js` mirror). Pipeline previously concatenated `series.styleNotes + description` with no character or setting context — every render re-improvised the cast. The shared composer prepends style → series name → setting baseline (when matched) → "Featuring — name: physical description" for the universal cast → the scene's visual prompt, with budget-managed truncation at 1900 chars. First step of the Writers Room ↔ Pipeline DRY unification (PLAN.md).
- World Builder is now a standalone Create page at `/world-builder` with its own sidebar entry, instead of being buried under Media Gen's tab strip. The old path `/media/world-builder` still renders the same component so existing bookmarks and `⌘K` / voice references keep working. Sets the stage for the upcoming Pipeline page, which will deep-link to World Builder without having to traverse the Media nav.
- Image Gen resolution dropdown now filters by the selected backend. Flux-bucketed sizes (832×1216 / 1216×832) only appear for Flux 1/2 and external; the new 1536 hi-res tier only appears for codex and Flux 2. Z-Image-Turbo and ERNIE (trained at 1024²) now hide both groups to prevent users from picking sizes the model can't handle. A stale incompatible selection falls through to a `(custom)` option so the value stays visible until the user picks a supported one.

## Fixed

- **Effective-model resolution for args-baked CLI providers.** Four callers (`stageRunner.runStagedLLM`, `worldBuilderExpand.expandWorldTemplate`, `worldBuilderRefine.refineWorldPrompts`, and `promptRunner.runPromptThroughProvider` itself) fell through to `provider.defaultModel || provider.models[0]` on the non-honoring CLI branch — but the actual model that runs for a CLI provider with a baked `--model`/`-m` flag in `provider.args` is the args-pinned id, which can diverge from `defaultModel`. Result: the run record, the pre-call log line, and the returned `llm.model` field could all lie when args win. Factored `mediaPromptRefiner`'s existing baked-model resolution into a shared `resolveEffectiveModel(provider, callerModel)` in `server/lib/promptRunner.js`, exported alongside `providerHonorsModelOverride`. It consults `hasModelFlag` + `extractBakedModel` from `services/runner.js` for the args-baked CLI case and returns the args-pinned id; otherwise it follows the existing override→defaultModel→models[0] chain. Replaced the inline logic at all five sites (`mediaPromptRefiner` was already on this pattern; the four above adopt it). `mediaPromptRefiner` and `worldBuilderRefine` no longer need to import `hasModelFlag` / `extractBakedModel` directly. Regression test in `promptRunner.test.js` pins that the run record + returned model reflect the baked id (not `provider.defaultModel`) when `extractBakedModel` resolves.

- **Creative Director evaluator prompt — backfill `imageStrength` block on existing installs.** `data.sample/prompts/stages/cd-evaluate.md` gained a `{{#scene.hasImageStrength}}` / `{{^scene.hasImageStrength}}` block in the scene-context section (so the evaluator can see the current i2v strength) and a sentence in the retry-branch instructions documenting the `imageStrength` knob it can adjust on rewrite. New installs picked it up automatically (fresh `data/` clones from `data.sample/`), but existing installs were stuck with the old template because `setup-data.js` only seeds files that don't yet exist — three regression tests in `creativeDirectorPrompts.test.js` flagged the drift on every test run. New one-shot migration `data/migrations/002-cd-evaluate-image-strength.js` surgically patches both pieces in when the surrounding anchor text matches the pre-update template (so a hand-edited prompt is left alone with a `⚠️` notice). Idempotent — re-runs are a no-op.

- **Media chips/search now show LoRAs for snake_case-only sidecars.** `normalizeImage` / `normalizeVideo` were building `loraNames` from only the camelCase `loraFilenames` / `loraPaths` shapes, so a sidecar written by the Python pipeline (`lora_filenames` / `lora_paths`) showed no LoRA chips on the card and didn't surface in LoRA-name search — even though the requeue path through `getRenderConfigForItem` resolved them correctly via `pickLoraFilenames`. Both normalizers now reuse `pickLoraFilenames` so chips, search, and requeue agree across all four field shapes (`loraFilenames` / `lora_filenames` / `loraPaths` / `lora_paths`). Three new tests in `normalize.test.js` pin the snake_case coverage for both image and video. Picked up from PR #223 Copilot review.

- **Modal a11y — close-button labels.** `MemoryEditModal` and `ResumeAgentModal`'s header X buttons are icon-only and were rendering as unnamed controls; added `aria-label` ("Close edit memory" / "Close resume agent modal") and marked the inner `<X>` icon `aria-hidden` so the button has a single, screen-reader-friendly name. Aligns the new shared-`<Modal>` call sites with the rest of the app's overlay-close conventions. Picked up from PR #223 Copilot review.

- **Mobile MediaLightbox — tap-to-toggle-drawer removed.** Follow-up to the mobile lightbox overhaul: tapping the image in fullscreen on mobile was toggling a bottom-sheet settings drawer (`max-h-[55vh]`) that sat absolute-positioned over the bottom half of the image area. Because the image was centered in a `flex-1` container the drawer wasn't pushing it up, just covering it — visually the image "vanished" with no obvious way back. Removed the tap branch and the now-unreachable `drawerOpen` state (effect cleanups, Esc cascade step, chevron `drawer-open` branch, `SettingsPane` fullscreen aside-class branch, `fullScreen ? 'Hide settings' : 'Close'` aria-label, the `tap for settings` hint). In fullscreen on mobile the image is now the whole canvas — use the minimize pill (top-right) to exit fullscreen and reach settings. Swipe-to-navigate still works. Also collapsed `SettingsPane`'s `onClose` + `onPrimaryClose` into a single `onClose` prop, moved `cleaning` state into `SettingsPane` (its only consumer), and replaced the inline `document.body.style.overflow` stash with the shared `useScrollLock` hook so lightbox + refine-modal can stack without clobbering each other's saved overflow.

- **Mobile MediaLightbox UX overhaul.** On a phone the lightbox was effectively unnavigable: prev/next chevrons rendered as faint `text-white/40` glyphs that disappeared on bright images, the swipe threshold was too strict for thumb arcs (`dx > dy×1.5`, 60 px), tapping in fullscreen opened a settings drawer that took the whole screen with no obvious dismiss, the chevrons sat at viewport `top-1/2` directly over landscape image content, and tapping the maximize/minimize button bubbled to the image div's tap handler — collapsing fullscreen AND toggling the drawer in the same gesture, leaving a `drawerOpen=true` state stranded under `fullScreen=false` that flashed the SettingsPane through the bottom-sheet layout on exit. Now: chevrons get a `bg-black/40 backdrop-blur` pill (visible on any image) and re-anchor to `bottom-4` in fullscreen so they sit in the letterbox bar for landscape renders; the fullscreen settings drawer is a bottom sheet (`max-h-[55vh]`) on mobile so the image + chevrons stay reachable above it (desktop keeps the side drawer); the swipe guard loosens to `dx > dy×1.2`, `SWIPE_MIN_PX=50`; touch handlers on the image area ignore events that originate inside a `<button>` via `e.target.closest('button')` so the max/min toggle no longer leaks into the tap-toggle; and a new `useEffect` clears `drawerOpen` whenever `fullScreen` falls back to false so the stale-state flash can't recur. The bottom-of-card hint also extends to `"tap for settings · swipe to navigate"` when prev/next exist.

- **Unified MediaLightbox wiring across all four gallery pages.** Image Gen, Media History, Media Collection Detail, and Video Gen each rolled their own `<MediaLightbox>` props block — same boilerplate (`item={preview}`, `onClose={() => setPreview(null)}`, the annotation lookup/patch dance) but with small drift: Media Collection Detail was missing `previewNavProps` entirely, so collections had no prev/next arrows or swipe nav at all, and it pre-filtered handlers by `preview.kind` despite `MediaLightbox` already gating the action buttons internally. The four call sites now share one wrapper, `client/src/components/media/MediaPreview.jsx`, that owns the close handler, the memoized `getMediaNavProps()` call, and the annotation wiring; nav props are spread after caller handlers so a stray `onPrevious`/`onNext` can't shadow the wrapper's contract. Collection view picks up prev/next nav as a side effect. Net diff: ~50 lines removed across the four pages.

- **DRY pointer cursor on interactive elements.** Tailwind v4 dropped the default `cursor: pointer` on `<button>`, which meant every clickable in PortOS had to spell out `className="cursor-pointer"` — and most didn't, so buttons hover-rendered with the text I-beam. A single global rule in `client/src/index.css` now restores pointer for `button`, `a[href]`, `summary`, `select`, clickable input types (checkbox/radio/submit/button/reset/file/range/color), `<label>` wrapping a checkbox/radio, and ARIA roles (`button`, `link`, `tab`, `menuitem`, `option`, `checkbox`, `radio`, `switch`). Placed before the existing `[disabled] { cursor: not-allowed }` block so disabled elements still get the correct override. Existing `cursor-pointer` utility classes are now redundant but harmless.
- Pipeline issues no longer get stuck in `status: 'running'` after a server restart mid auto-run. Boot now walks every issue and demotes any `running` to `needs-review` (the same terminal state a normal completion lands on) — the UI shows the issue as needing attention instead of spinning forever waiting for SSE that will never arrive.
- Pipeline EpisodeVideoStage final-video render no longer breaks when the underlying mp4 is missing — both surfaces (Creative Director SegmentsTab + Pipeline EpisodeVideoStage) now route through a shared `ScenePreview` component with the onError-missing-media fallback + Retry + cache-bust pattern.
- Render Queue UI now lists jobs in execution order (running first, then queue position ascending) instead of mixing a long-running job into the middle of newer queued batches. The route was sorting all jobs by `startedAt || queuedAt` DESC, which slotted an active job below a fresher batch.
- CoS agent dedup race in `spawnAgentForTask` — under sustained scheduler tick load, two parallel `task:ready` emissions for the same task id could both pass the dedup-has-check because `spawningTasks.delete(task.id)` ran BEFORE the agent was queued to the runner. A second spawn would slip through the window and produce a duplicate agent. The delete now lives inside a `try { spawnViaRunner|spawnDirectly } finally { spawningTasks.delete }` block so the guard outlives the spawn call. New behavioral race-repro test in `agentLifecycle.test.js` proves the contract.

- **Proactive TTS no longer round-trips through the mic as user input.** `speakProactive` broadcasts via `io.emit('voice:speak', ...)` with no per-socket context, so proactive lines were never written into `state.recentTts` — the echo-suppression buffer the pipeline checks before forwarding a transcript to the LLM. On a laptop with built-in mic + speakers, a proactive briefing playing aloud would round-trip through the mic on the next turn and the bot would reply to its own voice. A new module-scope echo-buffer registry in `server/services/voice/echo.js` solves it: each socket registers its `state.recentTts` array on connect and unregisters on disconnect; `speakProactive` calls `rememberTtsForAllSockets(trimmed)` just before the broadcast, so every connected socket's buffer sees the proactive line and the next turn's echo gate flags it. Surfaced by the pre-release /do:review pass.

- **`civitaiSuggestions.js` uses canonical `RUNNER_FAMILIES`.** Hand-rolled bare-string list `['mflux', 'flux2', 'z-image', 'ernie']` shadowed the new `Object.freeze({...})` constant in `server/lib/runners.js` introduced specifically to lock typo-prone runner ids in one place. Now imports the canonical map and uses `Object.values(RUNNER_FAMILIES)` so a future runner id change ripples through suggestion fetches automatically.

- **`messageEvaluator.js` routes through shared `extractJson` helper.** Email triage was still using a greedy `text.match(/\[[\s\S]*\]/)` extractor that throws raw `SyntaxError` on Codex CLI banner output or trailing-comma model emissions. Now routes through `server/lib/jsonExtract.js` (same path as world-builder expand, media prompt refiner, and stage runner) so banner stripping + trailing-comma repair + `[...]` placeholder elision apply to triage runs too.

- **`deepMerge` skips prototype-pollution keys.** The shared helper in `server/lib/objects.js` (used by voice config, loras Civitai-key, meatspace-post) now rejects `__proto__` / `constructor` / `prototype` as patch keys. Every current call site gates through Zod (which strips unknown keys), but the helper backs three routes and a future direct caller handing it `req.body` or an LLM tool-call payload could otherwise pollute `Object.prototype`. Defense in depth; no behavior change for valid input.

