# Release v1.45.0

Released: 2026-04-26

## Changed

- **PortOS browser launches headed by default; setup/update auto-opens the dashboard in it.** The CDP-managed Chrome instance (`portos-browser`) used to default to `--headless=new`, which made sense for pure CDP automation but meant a fresh `./setup.sh` left no visible browser window even though the user expected to land on the dashboard. Default flipped to headed (`browser/server.js`); `data/browser-config.json` can still set `headless: true` to opt back in for headless workflows. New `scripts/open-ui-in-browser.js` polls `/api/system/health` and `/api/browser/health` (90s API timeout, separate 15s browser/CDP timeout, 500ms interval) and then POSTs to `/api/browser/navigate` with the dashboard URL — `http://localhost:5553` (loopback HTTP mirror) when HTTPS is on so the first paint doesn't trip a cert warning, otherwise `http://localhost:5555`. Hooked into `setup.sh` (after `npm start` returns) and `update.sh` / `update.ps1` (after `npm run pm2:restart`); fail-soft so the parent script's exit code is unaffected by Chrome being unreachable. Files: `browser/server.js`, `scripts/open-ui-in-browser.js` (new), `setup.sh`, `update.sh`, `update.ps1`.

- **`npm run setup` is now the all-in-one install command.** Previously `setup` only ran `setup-data + setup-db + setup-browser` and `install:all` was the longer chain that ended with `&& npm run setup`. That meant a user typing `npm run setup` got an under-prepared workspace (no dependencies installed). Restructured: `setup` now does the full chain (submodules + workspace deps + esbuild postinstall + node-pty rebuild + data/db/browser setup) and `install:all` is a backward-compat alias that calls `setup`. `setup.sh` switched its primary call from `npm run install:all` to `npm run setup`. To keep `update.sh` / `update.ps1` from doing every install twice (their existing `safe_install` retry block + the new `npm run setup` superset), the post-install `npm run setup` call in both scripts was replaced with the three data-side scripts directly (`setup-data` + `setup-db` + `setup-browser`), preserving the resilient ENOTEMPTY retry while avoiding redundant npm installs. The auto-open script's API readiness timeout was also bumped from 30s → 90s (with a separate 15s timeout for the browser-CDP poll) since first-boot can exceed 30s on slow disks. Files: `package.json`, `setup.sh`, `update.sh`, `update.ps1`, `scripts/open-ui-in-browser.js`.

- **Death Clock now multi-color in every surface (DRY)** — extracted the colored 7-unit countdown from `CompactCountdown` in MeatSpace's Overview tab into a shared `DeathClockCountdown` component used by the Dashboard widget, the Ambient/fullscreen view, and MeatSpace itself. The values are now colored consistently (years/months/weeks/days/hours/minutes/seconds → blue/purple/teal/green/yellow/orange/red) with a `size` prop ('sm' for the dashboard widget, 'md' for the MeatSpace hero, 'lg' for Ambient) so each surface picks its own type scale without duplicating the unit/color list. Adds weeks to the Dashboard widget and Ambient view (was 6 units, now 7) to match the MeatSpace layout. Files: `client/src/components/DeathClockCountdown.jsx` (new), `client/src/components/DeathClockWidget.jsx` (use shared), `client/src/pages/Ambient.jsx` (use shared), `client/src/components/meatspace/tabs/OverviewTab.jsx` (use shared).

## Fixed

- **`portos-server` crash-loop on `Cannot find package 'multer'` from `videoGen.js`.** The new `server/routes/videoGen.js` was written against `multer` for `multipart/form-data` uploads but `multer` was never added to `server/package.json`, so a fresh `npm install --prefix server` left the server unable to import the route — `pm2 logs portos-server --err` showed an `ERR_MODULE_NOT_FOUND` loop and the API never bound to :5555. Fixed by switching to the existing `server/lib/multipart.js` helper (a stream-to-disk multer.diskStorage replacement that was already in the tree), which has the same `req.file = { path, originalname, mimetype, size }` contract. No new dependency added — per `CLAUDE.md`'s "default to writing code yourself", reusing the in-tree replacement is the right call. File: `server/routes/videoGen.js`.

- **`scripts/open-ui-in-browser.js` polled the wrong port when HTTPS was on.** The auto-open script's polling URLs (`/api/system/health`, `/api/browser/health`, `/api/browser/navigate`) were hardcoded to `http://localhost:5555`, but when a Tailscale cert is provisioned `:5555` speaks TLS only — every plain-HTTP request hit a handshake mismatch and timed out. The script computes a `LOCAL_BASE` once at startup: when `data/certs/cert.pem` exists it uses the loopback HTTP mirror (`http://localhost:5553`) for everything; otherwise plain `http://localhost:5555`. Same logic was already correct for the URL handed to the browser; now it's also used for the polls. File: `scripts/open-ui-in-browser.js`.

- **Federation peers stay offline after enabling Tailscale HTTPS.** When `npm run setup:cert` provisioned an HTTPS cert, every outbound federation hop (probe, query, announce, peer Socket.IO relay) started failing because Node's default `fetch()`/socket.io-client run with `rejectUnauthorized: true` and the peer's cert (real Let's Encrypt or self-signed fallback) couldn't always be verified by the calling instance. Per `CLAUDE.md`'s security model the Tailnet IS the trust boundary, so federation traffic doesn't need a second TLS validation layer on top of WireGuard. New `server/lib/peerHttpClient.js` exports a `peerFetch()` that uses `https.request` with a shared `https.Agent({ rejectUnauthorized: false, keepAlive: true })` for `https://` URLs and falls through to global `fetch` for `http://` URLs, plus a `peerSocketOptions` constant (`rejectUnauthorized: false`, `transports: ['websocket', 'polling']`) spread into the `io()` call in `peerSocketRelay.js`. The `peerBaseUrl()` builder is unchanged — the URL string is the same; only the dispatcher tolerates the cert. Two side issues fixed in the same pass: (1) the announce protocol now carries the announcer's Tailscale-issued hostname (`host` field added to `announceSchema` and to `announceSelf`'s POST body, sourced from a new `getSelfHost()` helper that reads `data/certs/meta.json` or `PORTOS_HOST` env override). `handleAnnounce` adopts the host on the receiving side when the peer record doesn't already have one, never overwriting a user-set value. This means bidirectional peer auto-creation finally produces records that can be reached over HTTPS instead of leaving an `address`-only row that can only speak plain HTTP. (2) Probe failures now log a classified one-line diagnostic (`DNS lookup failed`, `Connection refused`, `Host unreachable`, `Probe timeout`, `TLS reject`) instead of a generic `err.message`, so the next time something silently goes offline the cause is visible without strace. (3) `startPolling()` now clears `nextProbeAt`/`consecutiveFailures` on every peer at boot — a server restart is a clear "try again now" signal, and without this peers stuck at backoff tier 5 (24h) wouldn't auto-recover for up to a day after the fix is deployed. Files: `server/lib/peerHttpClient.js` (new), `server/lib/peerSelfHost.js` (new), `server/services/instances.js` (`peerFetch` everywhere, `getSelfHost()` in `announceSelf`, `host` adoption in `handleAnnounce`, `classifyProbeError`), `server/routes/instances.js` (`announceSchema.host`), `server/services/peerSocketRelay.js` (`peerSocketOptions`, `peerFetch` for initial agent fetch). Tests: `server/lib/peerHttpClient.test.js` (new, 2 tests for `peerSocketOptions` shape + `peerFetch` http fall-through), `server/lib/peerSelfHost.test.js` (new, 4 tests for env override / missing meta / tailscale mode / self-signed mode), `server/services/instances.test.js` extended with 4 new `handleAnnounce` host-flow tests.

- **Port story documented and startup banner clarified.** The unified port allocation has been correct in code since the Tailscale HTTPS work landed but docs and startup messages disagreed: `CLAUDE.md` still showed `health: 5554` as an example, the README told users to open `http://localhost:5554`, and the boot logs split the API URL and the loopback URL across two unrelated lines. There's now a single canonical statement of the rules: `:5555` is **always** the user-facing port (its scheme flips between HTTP and HTTPS based on whether `data/certs/{cert,key}.pem` exists, but the port number does not); `:5553` is a loopback-only HTTP mirror that spawns when HTTPS is on so curl/scripts skip cert warnings; `:5554` is dev-only Vite. The startup banner now prints all reachable URLs in one block (`https://<host>.<tailnet>.ts.net:5555`, `https://<tailscale-ip>:5555 (cert warning)`, `http://localhost:5553`), with the trusted hostname URL drawn from `getSelfHost()`. Files: `CLAUDE.md` (Port Allocation section corrected), `docs/PORTS.md` (new "How `:5555`, `:5553`, and `:5554` Relate" section with diagram), `README.md` (Quick Start + Network Access updated), `server/index.js` (banner), `client/vite.config.js` (one-line comment confirming the hardcoded `:5555` is intentional).

- **Codex 5.5 review round 1: navigation + lint + bundle** — addressed the four "Next 1-2 Days" items from `CODEX5.5_REVIEW.md`.
  - **Client lint runs again.** `npm run lint --prefix client` was crashing with `TypeError: expand is not a function` because the top-level `brace-expansion: ^5.0.5` security override was being inherited by ESLint's `minimatch@3` chain, which still consumes the v1 CommonJS API. Added a nested override (`"minimatch@3": { "brace-expansion": "^1.1.12" }`) so minimatch gets a patched-but-API-compatible version while everything else still gets v5. Fixing lint exposed real issues that pre-commit was silently skipping: `CharacterSheet.jsx:218` referenced an undefined `put` (should be `charPut`); `domIndex.js`/`voiceClient.js` used browser globals (`CSS`, `OfflineAudioContext`, `MediaRecorder`, `AudioWorkletNode`) that weren't declared in `eslint.config.js`. All errors fixed; lint is now 0 errors.
  - **Removed `/cos/scripts` dead route.** Sidebar + nav manifest pointed to a `scripts` tab that was merged into `Jobs` back in v1.34.0. Selecting Scripts from `⌘K`/voice/sidebar silently fell through to Tasks. Cleared the entry from both `Layout.jsx` and `navManifest.js`.
  - **Surfaced hidden-but-shipped tabs.** `Brain → Feeds` and `Digital Twin → Time Capsule` were rendered by their pages (`brain/constants.js`, `digital-twin/constants.js`) but absent from the sidebar and nav manifest, so users could only reach them by typing the URL. Added `nav.brain.feeds` and `nav.twin.time-capsule` entries (with aliases + keywords) and matching sidebar links.
  - **Fixed `/ask` height/layout brittleness.** The Ask page wraps a full-height chat shell, but `Layout.jsx`'s full-width route list omitted `/ask`, so it rendered inside the padded `max-w-7xl` wrapper and compensated with `h-[calc(100vh-4rem)] -m-4`. Classified `/ask` (and `/ask/:conversationId`) as full-width and switched the shell to `h-full` to inherit the layout's flex height.
  - **Nav contract test.** Added `lib/navManifest.test.js` cross-checks: for each tabbed page (`/brain`, `/cos`, `/digital-twin`, `/meatspace`), every nav manifest path must resolve to a real `TAB` id from the page's `constants.js`, and every `TAB` id must be reachable from the manifest. This is what would have caught all three nav drift bugs above. Constants files are read as text and TAB ids extracted with regex so the test runs in the server-only Node environment without pulling in client `lucide-react` imports.
  - **Bundle weight cut.** `App.jsx` was statically importing 24 page modules (Brain, DigitalTwin, MeatSpace, Wiki, Post, Settings, Insights, Loops, CharacterSheet, etc.) that the user never sees on first load. Switched all non-home pages to `lazyWithReload(...)`. Result: main `index-*.js` chunk dropped from **~2.95 MB / 736 KB gzip** to **295 KB / 76 KB gzip** (~4× reduction in initial download). Each lazy page now ships in its own chunk loaded on navigation.

  Files: `client/package.json` (nested minimatch override), `client/eslint.config.js` (browser globals), `client/src/pages/CharacterSheet.jsx` (charPut fix), `client/src/components/Layout.jsx` (sidebar entries + full-width route), `client/src/pages/Ask.jsx` (height fix), `client/src/App.jsx` (lazy imports), `server/lib/navManifest.js` (entries), `server/lib/navManifest.test.js` (contract tests).

## Added

- **pixie-forge integration: full local image + video generation, models management, and AUTOMATIC1111-compatible tailnet API.** PortOS now ships the entire UI/UX of the standalone `pixie-forge` app folded into the same monorepo. The Image Gen page is replaced with a richer port (model picker, LoRA list with per-LoRA scale, quantization 3–8 bit, gallery with lightbox + remix + send-to-video, deletes), a new Video Gen page (`/video-gen`) handles LTX models with image-to-video, frame count, FPS, and tiling, a Media History page (`/media-history`) shows the generated-video grid with ffmpeg-based stitching and "continue from last frame" piping back into Image Gen, and a Media Models page (`/media-models`) lists every cached HuggingFace model + LoRA with disk usage and one-click delete.

  - **Three-mode backend**: `settings.imageGen.mode` toggles between **External** (current AUTOMATIC1111/Forge passthrough — unchanged behavior, default for backward compat) and **Local** (mflux on Apple Silicon, diffusers on Windows). The dispatcher in `server/services/imageGen/index.js` makes `generateImage()` mode-agnostic — the existing `/api/image-gen/generate` endpoint, the CoS tools registry, and avatar generation all keep working without code changes regardless of the active mode.
  - **PortOS as an A1111 server on the tailnet**: a new gated `/sdapi/v1/*` surface (txt2img, options, sd-models, samplers, progress) exposes whichever backend PortOS is configured for to *other* tailnet machines, so a Mac running PortOS in Local mode can serve as the SD backend for any A1111 client across the LAN. Off by default — `Settings → Image Gen → Expose A1111 API` is the toggle.
  - **HF cache + new data categories**: PortOS honors any existing `HF_HOME` (or HF's default `~/.cache/huggingface`) — it intentionally does NOT override it, so users keep their existing model cache wherever it already is. The new Media Models page lists every cached HF repo from `<HF_HOME>/hub` (or the default) with on-disk sizes and one-click delete. DataManager picks up three new categories under `data/` itself: `videos`, `video-thumbnails`, and `loras` (LoRA adapter `.safetensors` files live alongside the gen output, not in HF cache).
  - **Optional install — mirrors voice**: `scripts/setup-image-video.sh` installs `mflux`, `mlx_video`, `mlx_vlm`, `transformers<5`, `safetensors`, `huggingface_hub`, `numpy`, `cv2`, `tqdm` (or torch/diffusers on Windows), plus `ffmpeg`. Like voice, this is opt-in — fresh PortOS installs run external-only until you flip mode to Local and point the Settings tab at a Python with these packages installed.
  - **SSE-driven local progress**: the local backend spawns a Python child and streams `STATUS:`/`STAGE:`/`DOWNLOAD:`/tqdm-style progress over per-job `/api/{image,video}-gen/:jobId/events` SSE. The same emissions feed `imageGenEvents` / `videoGenEvents` (bridged to Socket.IO) so existing socket consumers (the Image Gen progress hook, voice agent, etc.) light up identically across modes. Cancel sends SIGTERM to the active child.
  - **Existing CoS tool entry preserved**: `Stable Diffusion` tool registration in CoS tools registry continues to work and now follows whichever mode is active.

  Files: `server/services/imageGen/{index,external,local}.js` (new dispatcher + per-mode services; old `server/services/imageGen.js` removed), `server/services/videoGen/{local,events}.js` (new), `server/routes/{imageGen,videoGen,imageVideoModels,sdapi}.js` (videoGen, models, sdapi new; imageGen rewired through dispatcher), `server/lib/fileUtils.js` (new PATHS: `loras`, `videos`, `videoThumbnails`), `server/index.js` (new route mounts, new static dirs for `/data/videos` and `/data/video-thumbnails`), `server/services/socket.js` (video-gen socket bridge), `server/services/dataManager.js` (videos/video-thumbnails/loras categories), `server/lib/navManifest.js` (`nav.video-gen`, `nav.media-history`, `nav.media-models`), `client/src/pages/{ImageGen,VideoGen,MediaHistory,MediaModels}.jsx` (ImageGen rewritten, others new), `client/src/components/settings/ImageGenTab.jsx` (mode picker, local config, A1111 expose toggle), `client/src/services/apiImageVideo.js` (new), `client/src/components/Layout.jsx` (sidebar AI section), `client/src/App.jsx` (new lazy routes), `scripts/setup-image-video.sh` (new), `scripts/{imagine_win,generate_win}.py` (copied from pixie-forge for Windows path).

- **Brain → Import: guided ChatGPT data-export wizard** — first slice of a "import from third parties" workflow lives at `/brain/import`. The page presents a source picker (ChatGPT available now; Claude/Notion placeholders for future slices; Obsidian short-circuits to the existing Brain → Notes vault flow) and on selection drives a 6-step wizard: (1) inline OpenAI export instructions with a deep link to the official help article, (2) drag-or-browse upload of the extracted `conversations.json` with client-side parse + 50 MB guard, (3) preview pane showing total conversations, message count, character total, custom-GPT (`gizmo`) count, earliest/latest dates and a sample of titles, (4) configure tags + skip-empty toggle, (5) progress, (6) per-conversation results with a deep-link to `Brain → Memory` and an expandable list of imported/skipped IDs. Each conversation becomes a Brain Memory record (title, transcript truncated to fit the 10K content cap, tags including any `gizmo-*` indicator, `source: 'chatgpt-import'` and `sourceCreatedAt` set from ChatGPT's `create_time`) while the full structured transcript and message tree is archived to `data/brain/imports/chatgpt/<conversation-id>.json` so a richer viewer or re-tagger can be built later without re-uploading. The mapping-tree walk reconstructs the visible conversation thread by following `current_node` back to the root (so edited/regenerated branches don't pollute the import) and gracefully renders non-text parts (`image_asset_pointer` becomes `[image]`, etc.). New endpoints: `GET /api/brain/import/sources`, `POST /api/brain/import/chatgpt/preview`, `POST /api/brain/import/chatgpt`. Nav manifest registers `nav.brain.import` with aliases `brain-import`, `import-chatgpt`, `chatgpt-import` and keywords `chatgpt`, `openai`, `export`, `third-party` so `⌘K` and voice resolve "import chatgpt" / "chatgpt import" straight to the wizard.

  Files: `server/services/chatgptImport.js` (new — parser + Memory writer + transcript archiver), `server/routes/brainImport.js` (new), `server/services/chatgptImport.test.js` (new — 12 unit tests for tree walk, parts coercion, tag sanitisation, truncation, skip-empty), `server/routes/brainImport.test.js` (new — 4 route tests covering sources, preview validation, and import dispatch), `server/index.js` (mount), `server/lib/navManifest.js` (entry), `client/src/components/brain/tabs/ImportTab.jsx` (new — source picker + ChatGPT wizard), `client/src/pages/Brain.jsx` (case wiring), `client/src/components/brain/constants.js` (TABS entry), `client/src/components/Layout.jsx` (sidebar link), `client/src/services/apiBrain.js` (3 new helpers).

- **Image Gen page + custom-prompt test render in Settings** — the Image Gen settings tab no longer caps out at "test the connection". A new **Test Render** card lets you write a real prompt (and optional negative prompt) and watch a live diffusion preview, so saving the SD API URL is verified end-to-end without leaving the settings screen. A dedicated **Image Gen** page (`/image-gen`) is now reachable from the sidebar (alphabetized between Goals and Insights) and from `⌘K` (aliases `image-gen`, `imagegen`, `generate-image`, `sd`, `stable-diffusion`). The page exposes positive/negative prompts, six preset resolutions (square / landscape / portrait + Flux 832×1216 / 1216×832 + 1024² HD), Steps and CFG Scale sliders, a seed input with a randomize-die button, a live preview (base64 progressive frames piped through Socket.IO `image-gen:*` events), step/ETA readout, and a session history grid (last 24 renders, persisted to `localStorage`) with one-click "Reuse settings" and Download. UI guidance was lifted from the sibling `pixie-forge` project. The settings tab's previously bare `image-gen` alias was renamed to `image-gen-settings` so the bare alias now lands on the new page. The duplicated diffusion-progress socket subscription pattern (also present in `CharacterSheet.jsx`) was extracted into a shared `useImageGenProgress()` hook so the new ImageGenTab + ImageGen page consume one source of truth via `begin()` / `end()` lifecycle calls.

  Files: `client/src/pages/ImageGen.jsx` (new), `client/src/hooks/useImageGenProgress.js` (new), `client/src/components/settings/ImageGenTab.jsx` (Test Render card + hook adoption), `client/src/App.jsx` (lazy `/image-gen` route), `client/src/components/Layout.jsx` (sidebar entry), `server/lib/navManifest.js` (`nav.image-gen` Main entry, settings alias renamed to disambiguate).

- **"Ask Yourself" — conversational digital twin (slice a: text chat + sources)** — a new top-level page (`/ask`, deep-linked at `/ask/:conversationId`) that runs a retrieval-augmented chat against your own data. Every question fans out in parallel across memory (hybrid BM25 + vector), brain notes (ideas / projects / inbox), autobiography stories, goals, and calendar events; results are reranked with kind-weighted scoring (memory + goals weighted highest) and deduped by source id. The persona preamble is sourced from `character.json` so answers come back in the user's voice; three modes (`ask` / `advise` / `draft`) swap directives without changing the source pipeline. Streaming is wired end-to-end: API providers stream OpenAI-style SSE chunks; CLI providers fall back to a single-shot completion. Each assistant turn renders source chips that navigate to the origin section (Brain Memory, Goals, Calendar Agenda, Autobiography); chips for Goals deep-link to the specific record via existing `?id=` routing, while Brain Memory and Calendar Agenda chips land on the section page until those views grow record-level deep-link handling. Each chip carries the record id in `meta` so a future slice-b enhancement can wire record-level routing without changing the source contract. Conversations persist as JSON under `data/ask-conversations/<id>.json`, auto-expire after 30 days unless pinned (📌 button on the conversation header), and are listable in a sidebar. The user turn is persisted before streaming starts so a mid-stream disconnect still leaves the question on disk.

  **Nav integration**: registered in `server/lib/navManifest.js` (`nav.ask`) with aliases `ask`, `ask-yourself`, `twin-chat` so `⌘K` and the voice agent's `ui_navigate` resolve "ask" to `/ask` automatically. Sidebar entry alphabetized between Apps and Brain.

  **Slice (b) deferred**: voice integration, `ui_ask` tool, and one-click "Save as Brain note / Create CoS task / Attach to Goal" promotions will land in a follow-up.

  Files: `server/services/askService.js` (new — orchestration, retrieval, prompt assembly, streaming), `server/services/askConversations.js` (new — persistent storage with expiry + pin), `server/services/askService.test.js` (new — 11 tests), `server/services/askConversations.test.js` (new — 17 tests), `server/routes/ask.js` (new — SSE stream + list/get/delete/promote), `server/routes/ask.test.js` (new — 12 tests), `server/index.js` (mounts `/api/ask`), `server/lib/navManifest.js` (Ask entry), `client/src/services/apiAsk.js` (new — manual SSE reader since EventSource is GET-only), `client/src/services/api.js` (re-export), `client/src/pages/Ask.jsx` (new — chat transcript, source chips, mode switcher, conversation sidebar), `client/src/App.jsx` (routes), `client/src/components/Layout.jsx` (sidebar entry).

- **Global Command Palette (`⌘K`) with shared backbone for the voice agent** — the `CmdK` overlay has been upgraded from a content-search box into a true command palette. Typing fuzzy-matches across every navigable page (Dashboard, Brain, CoS sub-pages, MeatSpace tabs, Settings, etc.) and a curated set of voice-agent actions (Capture to Brain, PM2 status, Today's health summary, Current time, Feed digest, Recent Brain entries, etc.). Enter navigates or runs the action; arrow keys walk the list; Esc closes. The existing Brain/Memory/Apps/History/Health content search is preserved underneath as a second section so nothing regresses. Empty-query state shows a curated shortlist (Dashboard, Brain Inbox, Tasks, Goals, Review Hub, Capture to Brain, time, goal list, today's summary) so `⌘K` + Enter always does something useful.

  - **DRY backbone** — navigation is now defined once in `server/lib/navManifest.js` (`NAV_COMMANDS` + `resolveNavCommand` fuzzy resolver) and consumed by both the voice agent's `ui_navigate` tool and the palette via `GET /api/palette/manifest`. The old hand-synced 140-line `NAV_PAGES` map inside `server/services/voice/tools.js` is gone — adding a page to the manifest now updates both the sidebar's spoken/typed entrypoint AND the palette automatically, eliminating the "keep in sync with Layout.jsx" drift footgun.
  - **Palette-safe action whitelist** — `POST /api/palette/action/:id` dispatches a narrow subset of voice tools (brain/goals/meatspace/pm2/feeds/time/daily-log); DOM-driving tools (`ui_click`, `ui_fill`, `ui_select`, `ui_check`, `ui_navigate`) are intentionally excluded since the palette has no voice-agent DOM context to drive. Action descriptions + JSON Schema parameters are lifted from the same voice-tool registry, so palette hints and voice hints can never drift apart.
  - **Tests** — new `server/lib/navManifest.test.js` (9 tests covering shape invariants + fuzzy-match tiers + alias map) and `server/routes/palette.test.js` (5 tests covering manifest shape, action dispatch, whitelist enforcement). All 30 existing voice-tool tests continue to pass against the refactored resolver.
  - **Keyboard help modal updated** — the `?` shortcut list now advertises "Open command palette" instead of "Open search" and describes Enter as "Go to page, run action, or open result".

  Files: `server/lib/navManifest.js` (new), `server/routes/palette.js` (new), `server/services/voice/tools.js` (refactored), `server/index.js` (mounts `/api/palette`), `client/src/services/apiPalette.js` (new), `client/src/components/CmdKSearch.jsx` (palette behavior), `client/src/components/KeyboardHelp.jsx` (updated copy). Tests: `server/lib/navManifest.test.js`, `server/routes/palette.test.js`.

- **Drag-and-drop dashboard widget arrangement (free-form 12-col grid)** — every widget is now positionable. The Dashboard exposes an "Arrange" button next to the layout picker; in arrange mode every widget gets a top-right move handle and a bottom-right resize handle. Drag snaps to a 12-column × 80px-row grid with Tetris-style collision: the dropped widget pins where you let go, and surrounding widgets compact upward into the smallest non-colliding y position. Pointer events power the gesture (works for mouse + touch); `passive: true` on the window listeners + a snap-cell dedup guard mean a drag generates one React update per crossed snap boundary instead of one per pointermove. Save persists the new positions to the active layout's `grid` array; Cancel discards in-flight edits without writing.

  Layouts now carry `grid: [{ id, x, y, w, h }]` in addition to the existing widget list. When a layout's grid is empty (legacy + freshly-created layouts), the renderer falls back to `synthesizeGrid` — a row-flow auto-layout based on each widget's `width` keyword and new `defaultH` field. Below 640px viewport width the grid collapses to a single-column stack and drag/resize is disabled (touch targets would fight scroll on mobile). Adding/removing widgets via the existing LayoutEditor preserves saved positions: removed widgets drop out of the grid, added widgets are auto-placed at the bottom-left.

  All four built-in layouts ship with hand-tuned grids designed for a typical desktop viewport — most-useful widgets above the fold (~768px). **Everything**: capture row + Upcoming Tasks at top, then System Health (5-wide) with Proactive Alerts beside it, Activity Streak / Death Clock / Review Hub on the right, secondary widgets banded below, full-width Hourly Activity + Apps at the bottom. **Focus**: Quick Task compact top-left, tall Upcoming Tasks right, CoS below. **Morning Review**: Proactive Alerts top-left, tall Upcoming Tasks center, Death Clock + Goal Progress stacked top-right, Review Hub bottom-left — all 5 widgets above the fold. **Ops**: System Health top-left wide, Quick Stats top-right, CoS mid-right, Backup mid-left, Apps full-width bottom (h=11).

  Cross-field server validation: grid items must reference a widget in the layout's `widgets` list, must be unique by id, and `x + w` must fit inside `GRID_COLS`. Bounds (`GRID_COLS`, `GRID_ROW_MAX`, `GRID_ITEM_H_MAX`) are exported from the service and bundled into the `LIMITS` payload so the client can enforce the same caps.

  **Reuse**: extracted the inline `useContainerWidth` hook from `CalendarTab.jsx` into `client/src/hooks/useContainerWidth.js` (`ResizeObserver` + `[ref, width]` return) and wired both consumers to the shared hook.

  Files: `client/src/components/dashboard/DashboardGrid.jsx` (new — ~280 lines, free-form grid renderer + `synthesizeGrid` / `reconcileGrid` / `placeAndCompact` helpers), `client/src/hooks/useContainerWidth.js` (new), `client/src/pages/Dashboard.jsx` (Arrange/Save/Cancel buttons, draft-grid state, `renderGrid` reconcile pipeline), `client/src/components/dashboard/widgetRegistry.jsx` (`defaultH` per widget, `WIDTH_TO_COLS`, `GRID_COLS`/`GRID_DEFAULT_H` exports), `client/src/services/apiDashboard.js` (`saveDashboardLayout` accepts `grid`), `client/src/components/meatspace/tabs/CalendarTab.jsx` (uses shared hook), `server/services/dashboardLayouts.js` (`sanitizeGridItem`, grid-aware `sanitizeLayout`, hand-tuned default grids for all 4 built-ins, new `GRID_COLS`/`GRID_ROW_MAX`/`GRID_ITEM_H_MAX` exports), `server/routes/dashboardLayouts.js` (`gridItemSchema` + 3-refine `layoutSchema` chain), `server/routes/dashboardLayouts.test.js` (4 new test cases — grid round-trip, orphan rejection, x+w overflow, dup-id rejection, no-grid back-compat). `CLAUDE.md` documents the grid model, "Arrange" mode, and `defaultH` for future widget authors.

- **Customizable Dashboard with Saved Layouts** — the Dashboard is no longer a static hardcoded widget list. A widget registry (`client/src/components/dashboard/widgetRegistry.jsx`) describes each tile (`{ id, label, Component, width, gate? }`) and the Dashboard renders the active layout's ordered widget list over a 12-column grid. Four built-in layouts ship: **Everything** (the original), **Focus** (quick-task + cos + upcoming), **Morning Review** (alerts + upcoming + review-hub + goals + death-clock), **Ops** (system-health + cos + backup + apps + quick-stats). New layouts can be created via the layout picker → "Edit layouts…" → Save as new. Widgets can be added/removed per layout, reordered with ↑/↓ buttons (keyboard- and touch-accessible), and gated so data-dependent widgets (streak, hourly activity, quick stats) auto-hide when there's nothing to show. The Apps tile is intentionally un-gated — it renders its own "Add App" empty-state CTA on a blank install. Persisted to `data/dashboard-layouts.json` via `GET/PUT/DELETE /api/dashboard/layouts`; built-in layouts are protected from deletion. Inline-state delete/duplicate confirmations — no `window.confirm`/`prompt`.

  **Palette integration**: `⌘K` synthesizes one command per layout ("Dashboard: Focus", "Dashboard: Ops", etc.) so users can switch layouts from anywhere with a keystroke. Layouts re-fetch on every palette open so newly-created or renamed layouts show up without a page remount.

  Files: `server/services/dashboardLayouts.js` (new), `server/routes/dashboardLayouts.js` (new), `server/routes/dashboardLayouts.test.js` (new, 7 tests), `server/index.js` (mounts route), `client/src/services/apiDashboard.js` (new), `client/src/components/dashboard/{widgetRegistry.jsx,LayoutPicker.jsx,LayoutEditor.jsx,builtins/*}` (new), `client/src/pages/Dashboard.jsx` (fully rewritten), `client/src/components/CmdKSearch.jsx` (layouts section), `CLAUDE.md` ("Dashboard Widgets & Layouts" section with widget-entry contract).

## Fixed

- **Image Gen preview no longer flickers between progress and "Waiting for first preview..."** — `useImageGenProgress()` now merges progress events instead of replacing the whole state, so the throttled `currentImage` (server emits a base64 frame every ~2s while polling every 500ms) and any progress event missing `step`/`totalSteps` (some SD API states omit them) no longer cause the preview pane to revert to the empty/waiting state between frames. The hook also seeds initial progress from the `started` event (`step: 0, totalSteps: N`) so the status row shows "Step 0/N" immediately, never "Waiting for first preview..." once a render has begun. Affects both the Image Gen page and the Settings → Image Gen Test Render card. File: `client/src/hooks/useImageGenProgress.js`.

- **Dashboard layout picker dropdown no longer clips off-screen on mobile** — the layout selector menu uses `absolute right-0` to anchor its right edge to the trigger button. On a narrow viewport where the picker sits mid-row (after Server status, before Arrange/Ambient), the 224px-wide menu would extend past the left edge of the screen and cut off layout names ("ything", "us", "ning Review"). `LayoutPicker` now measures the trigger's bounding rect on open: when right-aligning would clip, it switches to `position: fixed` anchored to the viewport's right edge with an 8px margin instead. Added `max-w-[calc(100vw-1rem)]` so the menu can never exceed the viewport regardless of placement, `min-h-[40px]` on every row for the project's 40px touch-target standard, and `shrink-0` on the leading icon + trailing "Built-in" badge so they never collapse when the layout name is long. The dashboard header button row also wraps now (`flex-wrap items-center justify-end gap-2 sm:gap-3`) so on very narrow screens the controls re-flow instead of crowding into a single overflowing row. Files: `client/src/components/dashboard/LayoutPicker.jsx`, `client/src/pages/Dashboard.jsx`.

- **`./setup.sh` no longer leaves a fresh checkout half-installed when native PostgreSQL is chosen.** Two bugs combined to make the script exit "successfully" on a clean machine while the resulting app couldn't boot: (1) `scripts/setup-db.js` treated `pg_isready` as proof that the PortOS database was set up, so when a user already had Homebrew Postgres listening on 5432 and picked **2) Native PostgreSQL** at the prompt (or had `PGMODE=native` in `.env`), the script printed "✅ System PostgreSQL ready on port 5432" and exited 0 — never running `db.sh setup-native`, which is what creates the `portos` role, `portos` database, `vector`/`pgcrypto` extensions, and applies `init-db.sql`. The next `npm start` would then fail to connect with `portos`/`portos`. (2) `setup.sh` ran only the root `npm install` and never installed `client/`, `server/`, or `autofixer/` workspace deps, ran the esbuild postinstall, or rebuilt `node-pty`. The fix replaces those two calls with a single `npm run install:all` (which already does the full install + `npm run setup`) and adds a domain-level readiness probe (`isPortOSDbReady` — tries to authenticate as `portos@portos` and probe the `memories` table) to both native-mode branches in `setup-db.js`: when the probe passes the script exits fast (no startup-time hit on `npm start`/`npm run dev`, no recurring `ALTER USER ... PASSWORD` side effect against the `portos` role); when the probe fails — fresh checkout, missing role, missing schema, wrong password — the script falls through to `db.sh setup-native` to bootstrap. `setup.sh` also gains a final `Start PortOS now via pm2? (Y/n)` prompt (with reprompt on unrecognized input) so a fresh `git clone && ./setup.sh` can take the user from zero to a running pm2 stack on `http://localhost:5555` in one invocation. Files: `scripts/setup-db.js`, `setup.sh`.

## Changed

- **Dashboard Quick Task widget shows expanded options by default** — the compact form on the dashboard now opens with worktree/PR/simplify/review-loop/provider/attachments visible instead of hidden behind a "More options" toggle, so the card renders as a complete capture form on first paint. The expanded-on-mount behavior is opt-in via a new `defaultExpanded` prop on `TaskAddForm`, so the App detail page's Tasks tab (the other `compact` consumer) keeps its previous collapsed-first behavior. The toggle label now reflects state ("More options" → "Fewer options" once expanded). Both built-in layouts that include `quick-task` were re-tuned to match the taller capture row: **Everything** moves `quick-task`/`upcoming-tasks` to `h: 5` and shifts the second band (system-health, alerts, death-clock, review-hub, activity-streak) plus everything below down accordingly; **Focus** moves `quick-task` from `h: 2` to `h: 5`, lengthens `upcoming-tasks` to `h: 10`, and reanchors `cos` at `y: 5, h: 5` so the three widgets still fill the column without clipping the expanded form. The widget registry's `quick-task` `defaultH` was also bumped from 3 to 5 so legacy/auto-synthesized layouts (and any layout the user adds `quick-task` to) get a tile tall enough for the expanded form by default; the widget root is now `h-full flex-col` with a `min-h-0 overflow-y-auto` form area so users who manually shrink the tile still see a scrollable form instead of clipped controls. Files: `client/src/components/cos/TaskAddForm.jsx`, `client/src/components/QuickTaskWidget.jsx`, `client/src/components/dashboard/widgetRegistry.jsx`, `server/services/dashboardLayouts.js`.

- **Bump `portos-ai-toolkit` to 0.8.3** (from 0.8.0) — picks up the latest provider/run-tracking fixes from the toolkit. Server tests (2557) pass against the upgraded module. File: `server/package.json`, `server/package-lock.json`.

- **Command palette cleanup pass** — post-review simplify: `normalizeLabel` is exported once from `navManifest.js` and imported by `voice/tools.js` (was duplicated verbatim); `getToolMetadata(id)` added to `voice/tools.js` so `routes/palette.js` no longer reaches through the OpenAI-shaped function spec; palette manifest frozen at module load (`buildActionManifest` no longer runs per request) with `Cache-Control: private, max-age=300`. Client scorer precomputes lowercased `_label`/`_aliases`/`_keywords`/`_section` so per-keystroke fuzzy matching does zero re-lowercasing over the 117-entry manifest. `CmdKSearch.jsx` trimmed ~80 lines: collapsed the triple `useMemo` chain, merged `renderCommandRow` + inline search-result row into a single `renderRow`/`renderGroup` pair, flattened `dispatchCommand` into a `DISPATCH` lookup table, dropped the dead `running` state, removed two `.catch()` error-swallows (errors now propagate to the central toast handler per CLAUDE.md), cleared `resultRefs` on close. `ALIAS_KEYS` cached once in `resolveNavCommand`. Decorative section-banner comments and narration comments removed across the new files.

## Full Changelog

**Full Diff**: https://github.com/atomantic/PortOS/compare/v1.44.1...v1.45.0
