# Release v1.46.0

Released: 2026-04-28

## Full Changelog

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

## Added

- **CoS PR creation now supports GitLab repos via `glab`.** Previously hardcoded to `gh`, so any managed app pointed at a GitLab remote failed at the PR-creation step with no recovery path. `createPR` now parses the `origin` URL, detects the forge from the host (`github.com` → `gh`, `gitlab.com` / `*gitlab*.*` → `glab`, else `gh` fallback), and routes to the right CLI with the right argument shape (also parses the URL out of `glab`'s multi-line stdout). GitHub repos retain the multi-account `GH_TOKEN` pinning introduced alongside this change; GitLab repos use `glab` as-is (single-user-per-host keyring needs no pinning). Recovery tasks spawned by PR-creation failures now emit forge-correct retry commands and terminology — GitLab tasks get `glab mr` commands and "MR" wording instead of leading the recovery agent down a `gh` dead-end. The internal `gh*`-prefixed fields on `createPR`'s result (`ghAccount`, `ghHost`) were renamed to forge-agnostic `account`/`host` and `agentLifecycle` PR-result logs now include the cli + host so cross-forge failures are diagnosable from a single line. Merge-conflict recovery is unchanged (those commands are plain git, host-agnostic). Files: `server/services/git.js`, `server/services/git.test.js`, `server/services/agentLifecycle.js`, `server/services/cleanupAgentWorktree.test.js`.
- **CoS scheduled task: `do-replan` — weekly auto-audit of every managed app's PLAN.md.** New scheduled task type that runs the bundled `/do:replan` slashdo command against each managed app on a configurable cadence (default: **weekly**, **disabled per app** — enable individually from the Schedule tab). Pairs with the manual `/do:replan` button shipped in the same release: the button is for one-shot triggering, this task is for autopilot. When enabled, the agent spins up a worktree on the app's repo, follows the full `do:replan` flow (archive completed items to DONE.md, prune stale items, surface 1-3 new opportunities, rewrite PLAN.md to be lean), commits, and opens a PR for the user to review — never pushes directly to the default branch. The task prompt uses a new `{slashdoReplan}` placeholder that resolves at runtime to the live body of `lib/slashdo/commands/do/replan.md` (mirrors the existing `{reviewChecklist}` pattern), so the slashdo command stays the single source of truth and any future edits to it propagate automatically without prompt-version bumps. Existing apps see the new task as disabled (per-app overrides default to `enabled: false`); newly-created apps inherit the same default. Files: `server/services/taskSchedule.js` (new entry in `SELF_IMPROVEMENT_TASK_TYPES`, `DEFAULT_TASK_INTERVALS`, `DEFAULT_TASK_PROMPTS`; generalized slashdo cache from a single-purpose `_reviewChecklistCache` to a keyed `_slashdoCache`), `server/services/cos.js` (description copy), `client/src/components/UpcomingTasksWidget.jsx` (📋 icon), `client/src/components/cos/tabs/TaskItem.jsx` (description→type inference for replan/PLAN.md keywords), `server/services/taskSchedule.test.js` (3 new tests covering core-types membership, default interval shape, and `{slashdoReplan}` runtime substitution).
- **`/do:replan` button under each app's Agent Operations section.** Adds a sixth slashdo button to the Agent Operations row on every app's Overview tab (and to the server-side allowlist that gates `POST /api/cos/tasks/slashdo`) so the user can queue an automated PLAN.md audit/triage agent for any managed app the same way they queue `/do:push`, `/do:review`, etc. Clicking the cyan `/do:replan` button creates a CoS task pre-loaded with the upstream `lib/slashdo/commands/do/replan.md` content (which archives done items to DONE.md, prunes stale entries, and suggests new work), then routes the user to `/cos/agents` to watch it run. Files: `client/src/components/apps/SlashDoPanel.jsx`, `server/routes/cosTaskRoutes.js`.
- **Ask Yourself — slice (b) closes: voice tool + ⌘K palette action.** The text-chat half of "Ask Yourself" shipped on 2026-04-24, and per-turn promotions shipped on 2026-04-28; the remaining slice-(b) item — voice integration — is now done. New `ui_ask` voice tool in `server/services/voice/tools.js` consumes `askService.runAsk`'s async iterable (`sources`/`delta`/`done`/`error` events), accumulates the answer, and returns `{ ok, content, sourceCount, sources, providerId, model, summary }`. The tool's description tells the voice LLM to speak `content` verbatim, so the existing sentence-boundary TTS path in `pipeline.js` reads it aloud — no new client-side wiring required. Tool is gated by a new `ask` intent group with a tight regex (`ask my ?self|advise me|coach me|draft (a|an|my|me|something)|what.* on my plate|what (did|do|should) i (decide|think|believe|say|want|do)|why did i|when did i|recall (my|that|when)`) so small tool-use models (qwen3-4b, granite, etc.) only see it on RAG-style turns and don't get tempted to call it on plain UI/capture phrasings. `pipeline.js` now propagates the voice turn's `AbortSignal` through `ctx.signal` so barge-in tears down the upstream askService stream instead of silently leaking tokens into a closed connection. The palette half: `ui_ask` added to `PALETTE_ACTIONS` in `server/routes/palette.js` under section `Ask`, label `Ask Yourself` — `⌘K` exposes it with description/parameters auto-hydrated from the voice tool registry, and `POST /api/palette/action/ui_ask { args: { question, mode? } }` returns the answer + source kinds/titles synchronously so non-voice callers (palette, scripts) get the same RAG pipeline. 17 new tests (7 ui_ask tool, 5 ask-intent classifier, 3 ui_ask spec gating, 2 palette manifest + dispatch) — full server suite green at 2785/2785. Closes the last item in the PLAN.md "Ask Yourself — slice (b)" backlog. Files: `server/services/voice/tools.js`, `server/services/voice/tools.test.js`, `server/services/voice/pipeline.js`, `server/routes/palette.js`, `server/routes/palette.test.js`, `PLAN.md`.
- **Ask Yourself: one-click promote each assistant turn into a Brain note, CoS task, or Goal progress entry.** Closes the second half of "Ask Yourself — slice (b)" — the Ask page's twin-flavored answers were trapped in the conversation. Each assistant turn now shows three buttons below its source chips: **Save to Brain** (calls `brainService.captureThought` with the answer text), **Create task** (creates a CoS task with the answer as the description, capped at 280 chars and tagged with a "Promoted from Ask Yourself <id>" context note), and **Attach to goal…** (inline goal picker → `identityService.addProgressEntry` writes the answer as a `progressLog` note dated today). After a successful promotion the button mutates to a green "Saved to Brain"/"Task created"/"Attached: <goal title>" chip that deep-links to the saved record (`/brain/inbox`, `/cos`, `/digital-twin/goals?id=…`). Server-side, every promotion also pins the conversation (`promoted = true`) so anything saved elsewhere survives the 30-day expiry sweep — without that, a saved brain note could outlive the conversation it cites. New endpoint `POST /api/ask/:id/turns/:turnId/promote` validates a discriminated zod union — `{ target: 'brain' }`, `{ target: 'task', priority? }`, `{ target: 'goal', goalId }` — fails fast with 400/404/409 on missing turns, user turns, empty content, missing goals, or duplicate tasks. 13 new route tests; the full server suite remains green at 2746/2746. The remaining slice-(b) item — the `ui_ask` voice tool + palette whitelist — is still pending. Files: `server/routes/ask.js`, `server/routes/ask.test.js`, `client/src/services/apiAsk.js`, `client/src/pages/Ask.jsx`, `PLAN.md`.
- **Voice agent: `feeds_mark_read` tool.** Pairs with the existing `feeds_digest` so the user can say "mark that one read" / "mark them all read" / "mark all of Hacker News as read" without leaving the conversation. Two ways to call: `query` fuzzy-matches a single unread item by title (exact → substring → all-tokens fallback) using titles the LLM just spoke from `feeds_digest`; `all: true` marks every unread item read, optionally scoped to one feed via `feedQuery` (fuzzy-matched against feed title). The `feeds` intent regex was loosened to also match phrasings like "mark that one read" / "mark them all as read" / "headlines" so the tool is offered to the LLM on the right turns; the bare word "read" alone is intentionally NOT a feeds trigger (it collides with "read my log"). 11 new tests in `server/services/voice/tools.test.js` cover ok/error paths, feed scoping, fuzzy matching, and the new regex behavior. Files: `server/services/voice/tools.js`, `server/services/voice/tools.test.js`.
- **Reimagined theme system — four named themes with full design tokens.** Replaced the five ad-hoc color-only themes (Midnight, Phosphor, Ember, Arctic, Synthwave) with a structured manifest of four themes that each carry a complete design system, not just a color palette: **Classic Midnight** (the existing baseline, kept stable), **Lumen Glass** (translucent glass panes with backdrop-blur), **Black ICE Terminal** (dense cyberpunk terminal with scanlines and monospace UI), and **Blueprint Ops** (drafting-grid systems-map aesthetic). Each theme manifest declares colors (`--port-bg`, `--port-card`, etc), per-channel "on-X" foreground colors for accessible contrast on accent/success/warning/error pills, and a tokens block covering radii, font stacks (UI/display/mono), motion durations, shadow variants, backdrop-filter, body gradient/texture, control surfaces, hover/active states, terminal styling, and chart colors. A new runtime layer in `client/src/index.css` keys off `html[data-port-theme="<id>"]` so theme switches restyle the entire UI in one paint — including transitions on inputs/buttons, toaster border/shadow tokens (`client/src/main.jsx`), per-theme card backgrounds, sidebar treatments, and theme-specific flourishes (Black ICE scanline overlay, Blueprint Ops grid-pattern card backgrounds + heading rules, Lumen Glass focus glows). The theme picker has two surfaces: a redesigned dropdown in the bottom-bar `ThemeSwitcher` (portal-rendered, viewport-clamped, rAF-throttled scroll/resize repositioning, with family icons + accent swatch) and a new card-grid `ThemePickerPanel` in Settings → General that shows each theme's concept, density, four-color swatch row, and an active-state check. Legacy theme ids stored in `localStorage` or server settings are normalized via `LEGACY_THEME_ALIASES` (`midnight` → `classic-midnight`, `phosphor`/`synthwave` → `black-ice-terminal`, `arctic` → `lumen-glass`, `ember` → `blueprint-ops`) so existing users don't lose their preference; the migrated id is written back so future loads are direct. A new `npm run theme:check` script (`scripts/check-themes.js`, runs at any time) enforces the theme contract: every theme must declare every required color/token var with valid RGB channels, must point at a doc under `docs/themes/<id>.md` containing the four required sections (`## Intent`, `## Integration Rules`, `## Component Notes`, `## Validation`), every legacy alias must resolve to a real theme, and `index.css` must include a theme-specific selector for each non-default theme. Files: `client/src/themes/portosThemes.js` (new), `client/src/themes/familyIcons.js` (new), `client/src/components/ThemePickerPanel.jsx` (new), `client/src/components/ThemeSwitcher.jsx`, `client/src/components/ThemeContext.jsx`, `client/src/components/settings/GeneralTab.jsx`, `client/src/hooks/useTheme.js`, `client/src/index.css`, `client/src/main.jsx`, `docs/themes/*.md` (new — one per theme + README), `scripts/check-themes.js` (new), `package.json` (new `theme:check` script).
- **3D Chief of Staff avatars are draggable to re-orient.** All five 3D avatar styles (Cyber, Sigil, Esoteric, Nexus, Muse) now respond to typical mouse/touch drag controls — click-and-drag rotates the avatar around its center, with damping for a smooth feel. Pan and zoom are intentionally disabled so the framing stays consistent on the small avatar tile. Wrapper divs use `cursor-grab`/`active:cursor-grabbing` and `touch-none` so the affordance is visible and touch drags don't fight page scroll. Implemented via drei's `OrbitControls` inside each avatar's `<Scene>`. Files: `client/src/components/cos/CyberCoSAvatar.jsx`, `client/src/components/cos/SigilCoSAvatar.jsx`, `client/src/components/cos/EsotericCoSAvatar.jsx`, `client/src/components/cos/NexusCoSAvatar.jsx`, `client/src/components/cos/MuseCoSAvatar.jsx`.
- **Live AI operation status — toasts + server logs for every model call.** The user had no idea when a feature like Digital Twin → Taste → "Generate Summary" was actually doing work versus hung; the button just animated and silently waited up to a minute while LM Studio loaded a model on demand. New `server/services/aiStatusEvents.js` exports an `EventEmitter` + `startAIOp({ op, label, providerId, providerName, model })` helper that emits phase-tagged events (`start`, `model:loading`, `model:loaded`, `complete`, `error`) with provider/model/op metadata. `server/services/socket.js` bridges the emitter onto a new `ai:status` Socket.IO channel broadcast to all clients. `server/lib/aiProvider.js` — `callProviderAISimple()` now starts a status op for every API call (logs always; toasts when the caller passes `op`/`opLabel`, plus model-loading/error always), and the LM Studio auto-load path emits `model:loading` ("Loading openai/gpt-oss-20b into LM Studio…") + `model:loaded` ("openai/gpt-oss-20b loaded (4.2s)") so the user sees what's happening instead of a frozen button. `server/services/taste-questionnaire.js` passes labeled ops for the three taste AI calls (section summary, overall summary, deeper personalized question) so toasts read "Generating Movies & Film taste summary…" rather than a generic provider name. New `client/src/hooks/useAIStatusNotifications.js` subscribes to `ai:status`, renders one mutating toast per op id (`toast.loading` → `toast.success`/`toast.error` with the same id so phases update the same toast), and is mounted globally in `client/src/components/Layout.jsx`. Silent (uninstrumented) ops only toast when something user-visible happens (model load, error, or call exceeds 2.5s) so background calls don't spam the UI; explicit user-triggered ops toast immediately. Server logs are single-line emoji-prefixed with op slug, phase, message, provider, model, and elapsed time per CLAUDE.md style. Files: `server/services/aiStatusEvents.js` (new), `server/lib/aiProvider.js`, `server/services/socket.js`, `server/services/taste-questionnaire.js`, `client/src/hooks/useAIStatusNotifications.js` (new), `client/src/components/Layout.jsx`.

## Fixed

- **CoS improvement queue stalls and "must be a collaborator" PR errors when multiple `gh` accounts are logged in.** Three independent failure modes that each held the queue back, now all addressed. (1) **Wrong-account PR creation**: when more than one `gh` account was logged in, a `gh auth switch` in another shell could redirect bot-triggered PR creation to the wrong account, which GraphQL rejected with "must be a collaborator". `createPR` now parses the repo owner from `origin`, looks up which logged-in `gh` account matches that owner via `gh auth status`, and pins `GH_TOKEN` for the spawn so the active CLI selection in another shell can't bleed into the agent. Falls back to the active user when no logged-in account matches. (2) **Recovery tasks bumping the per-app cooldown**: tasks tagged `metadata.isRecovery` (or prefixed with `[Recovery]`) used to bump the per-app review cooldown like a normal improvement task, pushing every sibling improvement task another full window into the future and stalling the queue — recovery work is administrative and shouldn't count. Centralized the recovery-marker test in a new `recoveryTasks.js` and gated the cooldown bump on it. (3) **Per-app improvement-task pile-up**: `queueEligibleImprovementTasks` could enqueue multiple task types (code-quality, test-coverage, feature-ideas) on the same app faster than the 30-min per-app cooldown could drain them. Now caps each app at one pending improvement task at a time so the cooldown actually serializes throughput. New tests in `agentCompletion.test.js` and `git.test.js`. Files: `server/services/git.js`, `server/services/git.test.js`, `server/services/agentLifecycle.js`, `server/services/agentCompletion.js`, `server/services/agentCompletion.test.js`, `server/services/cos.js`, `server/services/recoveryTasks.js` (new).
- **CoS on-demand task triggers no longer silently drop when the master Improve toggle is off.** Triggering a scheduled task (e.g. `feature-ideas` for `critical-mass`) showed a "queued" toast but nothing ever ran — the only log was `On-demand task requested: <type>`. Root cause: `dequeueNextTask`/`evaluateTasks` cleared the request and `continue`d without logging when `state.config.improvementEnabled` was `false`, and `triggerOnDemandTask` only validated the per-task-type `enabled` flag, so the request was accepted then quietly dropped downstream. Fix: (1) `triggerOnDemandTask` now checks the master flag and rejects with `Improvement is disabled — enable it in CoS → Config to run on-demand tasks`; the trigger route propagates that as HTTP 409 so the user sees a real error toast. (2) The two silent-drop branches now `emitLog('warn', …)` so any path that bypasses the trigger guard is still visible. (3) `getScheduleStatus` now returns `improvementEnabled` so the Schedule tab can render a banner ("Improvement is disabled — enable it in CoS → Config") and disable every Run Now / Run on App button (with an explanatory tooltip) instead of letting users queue requests that won't run. (4) Extracted `isImprovementEnabled(state)` in `cosState.js` to replace the same nullish-coalesce expression duplicated 6× across `cos.js` and `taskSchedule.js`. Files: `server/services/cosState.js`, `server/services/taskSchedule.js`, `server/services/cos.js`, `server/routes/cosScheduleRoutes.js`, `server/routes/cosScheduleRoutes.test.js`, `client/src/components/cos/tabs/ScheduleTab.jsx`.
- **CoS agent PRs no longer dump the entire thought-process narration into the PR body.** PR [#172](https://github.com/atomantic/PortOS/pull/172) is the canonical example: instead of just the agent's `## Summary` block, the PR description prepended every interim "Now I have all the info…" / "Now let me run the relevant tests…" / "Branch pushed successfully. Task complete." line. Root cause was in `createStreamJsonParser` in `server/services/agentCliSpawning.js` (and the mirrored copy in `server/cos-runner/index.js`): the parser called `commitSection()` on every `content_block_start` for a `tool_use`, which pushed the text accumulated *between* tool calls into `textSections`. A multi-tool task ended up with one section per pre-tool narration plus the final summary; `getFinalResult()` joined them all with `\n\n` (the multi-section path is intentional — it's how `task summary + /simplify summary` are preserved across turns) and that joined blob replaced `outputBuffer`, stripping all `🔧`/`→`/`↳` markers from `output.txt`. Downstream `git.extractAgentSummary()` then had no markers to anchor on and emitted everything verbatim. Fix: at tool-start, *discard* `currentTextSection` instead of committing it — interim narration leading up to a tool call is superseded by whatever the agent says after the tool returns, so only the post-last-tool wrap-up survives into `textSections`. Result events still commit (preserves task-then-/simplify two-result-event scenarios). 3 new regression tests in `server/services/agentCliSpawning.test.js` (new) cover the multi-tool single-turn case, the multi-result task+/simplify case, and the single-result baseline; full server suite green at 2778/2778. Files: `server/services/agentCliSpawning.js`, `server/cos-runner/index.js`, `server/services/agentCliSpawning.test.js` (new).
- **Sidebar nav labels truncate instead of pushing the container wider.** Long nav labels (e.g. third-party app names) could force the sidebar's flex container to overflow horizontally on narrower viewports, distorting the rest of the layout. Added `min-w-0` to every flex parent in the nav row tree and `truncate` to the label `<span>` elements (top-level links, section buttons, and child links), plus `shrink-0` on the child icon so it can't be squeezed out, and `overflow-x-hidden min-w-0` on the `<nav>` itself so the scroll axis stays vertical. File: `client/src/components/Layout.jsx`.
- **CoS agent PRs now actually open.** Every recent CoS-agent automation that finished its work and pushed its branch was failing at the PR-creation step with `Failed to create PR for <branch>: spawn is not defined`, then triggering `[Recovery] Investigate and retry failed PR for branch …` recovery tasks for the orchestrator to clean up. Root cause: a refactor (commit `17311282`) extracted `execGit` to `server/lib/execGit.js` and dropped the `import { spawn } from 'child_process'` line from `server/services/git.js` — but `createPR()` still calls `spawn('gh', …)` directly, so every PR creation hit a `ReferenceError`. The existing `cleanupAgentWorktree` test suite mocked `git.createPR` so the bug never surfaced in CI. Restored the import and added `server/services/git.test.js` with a regression test that calls the real `createPR` against a non-existent path: success path returns `{ success: false, error: '<gh failure>' }` instead of throwing. The recovery flow continues to work as a safety net, but agents will now hit the happy path and open the PR directly. Files: `server/services/git.js`, `server/services/git.test.js` (new).

- **Mobile responsiveness — narrow forms and wide tables no longer break on small screens.** Four spots were squeezing or clipping content under ~640px wide viewports: (1) the Create App form's Port fields used `grid-cols-3` so the three port inputs (UI, Dev UI, API) crammed into ~100px each on a phone — now `grid-cols-1 sm:grid-cols-3` so they stack on mobile and only side-by-side on `sm:` and up; (2) the Body tab's eye-exam history table has eight columns (Date, L SPH/CYL/AXIS, R SPH/CYL/AXIS, actions) and was wrapped in `overflow-hidden` so the rightmost columns were invisible on mobile — now wrapped in `overflow-x-auto` with `min-w-[640px]` on the table so it scrolls horizontally instead of clipping; (3) the Posture session-history table (5 columns) had the same `overflow-hidden` wrapper — now `overflow-x-auto` with `min-w-[520px]`; (4) the Data Manager Backup Archives table (Archive name + Size + Created + delete button) — same fix with `min-w-[480px]`. Files: `client/src/pages/CreateApp.jsx`, `client/src/pages/DataManager.jsx`, `client/src/components/meatspace/tabs/BodyTab.jsx`, `client/src/components/meatspace/post/PostHistory.jsx`.

- **Taste summaries render as markdown and appear inline immediately after generation.** The Digital Twin → Taste tab had two regressions in the summary flow: (1) after clicking "Generate Summary" on the section-complete view, the button returned to its enabled state with no visible change — the summary was saved but not rendered until the user navigated away and back into the section's Review view, and (2) every place the summary did show (review-mode AI Summary card, expandable summary on the dashboard grid, "Unified Taste Profile" overall summary) used a `whitespace-pre-wrap` `<div>` so the LLM-generated markdown displayed as raw `### Movies & Film Profile`, `**Core Preferences:**`, `- Item 1` etc. instead of styled headings, bold text, and bullets. Now: the section-complete view shows the freshly-generated summary inline in a violet-bordered card the moment `loadProfile()` returns, and all three summary surfaces use the existing `MarkdownOutput` component (which already powers CoS, autobiography, etc.) so headings/bold/italics/bullets/blockquotes render properly. File: `client/src/components/digital-twin/tabs/TasteTab.jsx`.

- **Voice mode works on Windows.** The voice setup script and TTS engine now run correctly on Windows: `bootstrap.js` invokes `pwsh` with a PowerShell setup script (`scripts/setup-voice.ps1`) instead of the macOS-only bash/Homebrew script; `which` uses `where` on Windows; `verifyBinaries` checks the local piper install path first (skipping a wasteful PATH subprocess when the binary is present); `tts-piper.js` writes piper output to a temp file on Windows to avoid CR+LF corruption of binary WAV data from text-mode stdout. Shared platform constants (`IS_WIN`, `PIPER_BIN_NAME`) extracted to `config.js`.
- **Voice agent no longer feedback-loops on laptop speakers.** When the user ran the voice widget without headphones, TTS played through the laptop speakers, the built-in mic picked it up, STT transcribed the bot's own voice as a "user" turn, the bot replied to itself, and the loop repeated until the user manually stopped it. The existing substring-based echo gate in `voiceClient.js` was too brittle — STT mistranscribes TTS just enough that the heard text wasn't a clean substring of any spoken sentence, so the gate let the echo through. Replaced with a length-gate + trigram-overlap algorithm: utterances under 4 tokens always pass (so short barge-ins like "wait" / "stop" still interrupt the bot even if those words appear in TTS), and longer utterances are flagged as echo when they share ≥ 2 trigrams (3-word windows) with a recently-spoken sentence OR are a clean substring. Same algorithm runs server-side in a new `server/services/voice/echo.js` (per-socket `state.recentTts` ring) so continuous-mode whisper transcripts are also gated, not just Web Speech. Also added device-route detection: when the active mic shares a `groupId` with an audiooutput device AND the label looks headset-y (AirPods, Jabra, etc.), the gate is bypassed entirely (no acoustic echo path on real headphones); a `devicechange` listener re-evaluates on plug/unplug. Default-tight on uncertainty — false-headset would re-open the loop, false-built-in just makes barge-in slightly less twitchy. Files: `server/services/voice/echo.js` (new), `server/services/voice/echo.test.js` (new, 16 tests), `server/services/voice/pipeline.js`, `server/sockets/voice.js`, `client/src/services/voiceClient.js`.
- **Voice agent now navigates to the daily log via `ui_navigate`.** Three related issues caused "take me to the daily log" to fail or behave unexpectedly. (1) The `dailylog` intent regex used `\bdaily ?log\b`, which doesn't match STT typos like "daily login" / "daily logs" — so `daily_log_open` was filtered out of the offered toolset, the LLM only had `ui_navigate`, but `ui_navigate`'s description told it NOT to use that tool for daily log, leaving the model to fabricate a refusal ("Daily log isn't a valid page"). Loosened to `daily ?logi?n?s?` to absorb the drift. (2) Removed the contradictory "EXCEPT Daily Log (use daily_log_open)" carve-out from `ui_navigate`'s description and from the system prompt; the nav resolver already maps "daily log" / "journal" → `/brain/daily-log` correctly. The new rule: plain "go to / open / take me to my daily log" → `ui_navigate`; only pick `daily_log_open` when the user clearly wants to write/dictate ("start", "new", "entry", "make", "dictate"). (3) Extended `detectNarrationWithoutCall` to also catch the second narrate-without-call shape — the LLM emitting a literal tool name in its prose (e.g. `ui_navigate page="daily_log"`) without actually invoking it. The regex is now built dynamically from `getAllToolNames()` so it can't drift behind new tools. Files: `server/services/voice/tools.js`, `server/services/voice/pipeline.js`.
- **Reverting a peer to plain IP (after enabling Tailnet DNS) is now sticky.** When a user cleared a peer's `host` on the Instances page to switch the federation hop back to `http://<ip>`, the next inbound `peers/announce` from that peer would silently re-adopt the DNS name — the only safeguard was "is `existing.host` empty?", which couldn't distinguish "user explicitly cleared" from "never set yet." Added a `hostManual` flag on each peer record: latched to `true` whenever the user sets or clears the host via `PUT /api/instances/peers/:id`, and respected by `handleAnnounce` (auto-adoption is gated on `!existing.host && !existing.hostManual`). Fresh peers and peers that were auto-learned still receive automatic host updates from announces; only explicit user intervention pins the choice. Also added a one-click `use IP only` inline action on each peer's host editor and a discoverable banner at the top of the Instances page that summarizes Tailnet DNS state, surfaces the local instance's MagicDNS name in the SelfCard, and walks through the three-step setup (MagicDNS admin toggle → `npm run setup:cert` → click `use <host>` per peer). Files: `server/services/instances.js`, `server/services/instances.test.js`, `client/src/pages/Instances.jsx`.
- **LM Studio auto-loads a model on "No models loaded" 400.** Any caller of `callProviderAISimple` (taste questionnaire, identity, autobiography, insights, jiraReports, goalCheckIn) that hit a cold LM Studio used to error out with `Provider returned 400: { ... "No models loaded" ... }`. Now on that specific error pattern, the helper lists LM Studio's downloaded LLMs via `/api/v0/models`, picks one (preferring `provider.defaultModel`, then any id in `provider.models` that's downloaded, else the first downloaded LLM), loads it via `POST /api/v1/models/load`, and retries the chat-completion. Mirrors the existing pattern in `services/memoryClassifier.js` but driven from the provider config so every API caller benefits. File: `server/lib/aiProvider.js`.
- **Stop showing the same error toast 3-4 times.** A single failing API call could surface its error message multiple times because three independent channels all toasted the same payload: `apiCore.request` toasts on every non-OK response, the server emits both `io.emit('error:occurred')` (broadcast) and `errorEvents → broadcastToErrors('error:notified')` (subscriber) for the same error, and `useErrorNotifications` listened to *both* socket events with the same handler. Two changes: (1) `useErrorNotifications` now subscribes to the targeted `error:notified` channel only — `error:occurred` was the duplicate broadcast leg — and (2) the Toast layer added a content-based dedup window (1.5s) so any duplicate from any source is silently dropped. Explicit-`id` toasts (loading→success swaps) skip dedup so progress toasts still update in place. Files: `client/src/components/ui/Toast.jsx`, `client/src/hooks/useErrorNotifications.js`.
- **Media Gen Video defaults to the smallest model.** The macOS default was LTX-2 Unified (~42 GB). New default is LTX-2.3 Distilled Q4 (~22 GB) so a fresh install fits on smaller machines without a manual override. File: `server/services/videoGen/local.js`.
- **Taste questionnaire falls back to a configured API provider.** The Digital Twin → Taste tab was auto-selecting the first enabled provider for `Generate Summary` / `Go Deeper`. When that happened to be a CLI provider (Claude Code, Codex, Gemini CLI), the request failed with a generic "requires an API-based provider" toast — three times, because both the API client and two socket subscribers re-toasted the same error. Now (1) the section provider picker filters to API-type providers only, (2) the server resolves any usable API provider when the requested one isn't compatible (requested → active → first enabled API) via a new `resolveAPIProvider` helper in `server/lib/aiProvider.js`, (3) the section-complete view shows an inline link to AI Providers when no compatible provider is configured instead of leaving the user with a wall of red toasts and no direction, and (4) the redundant component-level `toast.error` calls were removed (apiCore already toasts on non-OK responses). Files: `server/lib/aiProvider.js`, `server/services/taste-questionnaire.js`, `client/src/components/digital-twin/tabs/TasteTab.jsx`.

## Changed

- **Voice agent FAB is hidden by default; sidebar Voice toggle engages, starts listening, and reads as a distinct violet layer.** The push-to-talk widget previously rendered as a floating bottom-right FAB on every page the moment voice was enabled, covering content even when not in use, and styled with the same neutral card border as everything underneath it. Three changes: (1) Replaced the sidebar/mobile-header `ThemeSwitcher` (theme picker still in Settings → General) with a new `VoiceToggleButton` that flips `localStorage.portos.voice.hidden` between `'1'` (hidden) and `'0'` (engaged), dispatches the existing `portos:voice:visibility` event for the widget to re-render on, and additionally fires `portos:voice:engage`/`portos:voice:disengage` so the widget starts/stops capture in lockstep — clicking once now both shows the controls and opens the mic, instead of forcing two clicks. (2) `VoiceWidget` defaults to hidden when the key is unset (legacy users included), so the FAB only appears after the user explicitly engages voice from the sidebar; the same toggle in Settings → Voice keeps working with matching `'0'`/`'1'` semantics. (3) The floating widget surfaces (status pill, text-input form, conversation panel, mobile FAB) now wear a `border-violet-500/50` ring with a `shadow-[0_0_24px_-4px_rgba(168,85,247,0.55)]` glow, and the primary mic button is violet-tinted (`bg-violet-500` while capturing, `bg-violet-500/20` idle), so the voice layer reads as a distinct interactive surface instead of blending into whatever card it's covering. Files: `client/src/components/Layout.jsx`, `client/src/components/voice/VoiceToggleButton.jsx` (new), `client/src/components/voice/VoiceWidget.jsx`, `client/src/components/settings/VoiceTab.jsx`.
- **Execution lanes are now priority tags, not concurrency gates.** `server/services/executionLanes.js` previously had its own `maxConcurrent` caps per lane (critical: 1, standard: 2, background: 3) on top of the user-configurable global/per-app caps in `cos.js` (`maxConcurrentAgents`, `maxConcurrentAgentsPerProject`). The lane gates were silently shadowing the user's settings — bumping `maxConcurrentAgents` to 8 still capped throughput at 6 because the lane subsystem refused to accept the 7th and 8th agents — and on backpressure `agentLifecycle` would `waitForLane` for up to 30s before bailing, which manifested as "agent stuck queueing" in the UI for tasks that should have spawned immediately. Removed the per-lane cap, the lane-internal waiting queue, the timeout/promotion-on-capacity path, and the `hasCapacity`/`waitForLane`/`updateLaneConfig` exports; `acquire` now always succeeds for known lanes, `promote` no longer rejects when the target lane is "full", and `getStats`/`getLaneStatus` drop the `maxConcurrent`/`available`/`queueLength`/`utilizationPercent` fields. Lanes still carry `priority` and per-agent `{ startedAt, taskId, metadata }` for observability. `agentLifecycle.spawnAgentForTask` now just tags the agent's lane and continues — global capacity is enforced once, upstream, where the user can actually configure it. Existing tests rewritten to match: 5 deleted (capacity/wait/queue cases), 3 reframed to assert "lane is a tag, not a gate", and the `cleanupAgentWorktree` mock simplified to remove `hasCapacity`/`waitForLane`. Files: `server/services/executionLanes.js`, `server/services/executionLanes.test.js`, `server/services/agentLifecycle.js`, `server/services/cleanupAgentWorktree.test.js`.
- **Dashboard fills the full content area on wide monitors.** The Dashboard page was wrapped in `max-w-7xl mx-auto` like the rest of the app, so on monitors wider than ~1280px there were huge empty side margins next to a relatively narrow widget grid. Since the dashboard's grid renderer is already designed to flex across whatever space it's given (12-column grid, snap-to-grid drag/resize), the cap was wasted real estate — moved the dashboard route (`/`) out of the capped branch in `Layout.jsx` so widgets now fill the full width of the main content area while keeping the existing `p-4 md:p-6` gutter for a consistent margin. No behavior change on smaller viewports (where the cap was never reached) or on other pages. File: `client/src/components/Layout.jsx`.
- **Test coverage: +89 tests across 11 previously-uncovered modules.** Added focused unit tests for low-level utilities and route handlers that had 0% coverage in the baseline coverage report: `lib/sseUtils.js` (broadcast/attach/cleanup of SSE clients and the Python-noise filter), `lib/tailscale.js` (CLI-binary discovery across PATH and well-known install paths), `lib/zipStream.js` (streaming ZIP parser — stored + DEFLATE entries, multi-chunk arrival, path-traversal sanitization, autodrain), `lib/execGit.js` (timeout, maxBuffer overflow, ignoreExitCode, spawn-error paths), `routes/avatar.js` (404 + streaming + ENOENT/EIO mid-stream errors via deferred-emit Readable), `routes/agents.js` (kill/list/info with status + body-shape assertions), `routes/notifications.js` (full CRUD, 404 propagation), `routes/usage.js` (summary/raw/session/messages/tokens/reset), `routes/backup.js` (status, run with/without dest, snapshots, restore + zod validation), `services/agentDrafts.js` (per-agent CRUD, sort by createdAt via fake timers, scoping), `services/brainGraph.js` (Jaccard threshold, bridge remap, missing-bridge dropping, dedup with memory edges, getGraphData failure fallback). Total server suite: 2633 → 2722 tests across 103 → 114 files; all pass.
- **Refactor: 4 message/CDP modules now share one `browserService.js` for Chrome DevTools Protocol calls.** `cdpRequest`, `listCdpPages`, `findOrOpenPage`, `isAuthPage`, and `evaluateOnPage` had drifted into duplicated (or near-verbatim) copies across `messagePlaywrightSync.js`, `messageActions.js`, `messageTokenExtractor.js`, and `googleOAuthAutoConfig.js`. Centralized them in `browserService.js` with a per-request timeout option (named constants for the 10s default and 60s evaluate timeouts), and replaced `messageTokenExtractor`'s near-verbatim `triggerPageRequest` copy with `evaluateOnPage` carrying an explicit timeout. Existing `browserService` helpers (`navigateToUrl`, `getOpenPages`, `getCdpVersion`) now build on `cdpRequest` instead of inlining `fetch` + host logic. `messagePlaywrightSync` re-exports the helpers as a compat shim so existing consumers and test mocks keep working unchanged. Net -34 lines across the three core files; full server suite still green. Files: `server/services/browserService.js`, `server/services/messagePlaywrightSync.js`, `server/services/messageTokenExtractor.js`.
- **Refactor: 19 files now use `fetchWithTimeout` instead of hand-rolled `AbortController + setTimeout + fetch + clearTimeout` blocks.** Net 75 lines deleted across `server/services/{browserService, calendarApiSync, digital-twin-helpers, feeds, insightsService, lmStudioManager, memoryClassifier, memoryEmbeddings, messageApiSync, messagePlaywrightSync, messageTokenExtractor, pm2Standardizer, syncOrchestrator, visionTest}.js`, `server/services/voice/{bootstrap, health, stt}.js`, and `server/integrations/{moltbook, moltworld}/api.js`. The helper at `server/lib/fetchWithTimeout.js` already existed and was tested — most call sites just hadn't been migrated. Each file also extracts its magic timeout numbers (3000, 10000, 30000, 60000, 120000, 180000, 300000) to named constants like `HEALTH_TIMEOUT_MS`, `GRAPH_API_TIMEOUT_MS`, `MODEL_LOAD_TIMEOUT_MS`, `DEFAULT_AI_TIMEOUT_MS` so the request lifecycle stops being four scattered statements per call. `browserService.js` additionally hoists three duplicate dynamic `await import('child_process')` / `await import('util')` blocks (one per PM2 helper) to a single top-level import + `execFileAsync` / `PM2_SHELL` constants, and `messageApiSync.js` lifts a per-401 dynamic `await import('./messageTokenExtractor.js')` to a top-level import. All 2633 server tests still pass.
- **Vite dev proxy splits `/data` into per-subpath rules.** Replaced the single catch-all `/data` proxy with explicit `/data/images`, `/data/videos`, and `/data/video-thumbnails` entries so app-managed `data/` directories (CoS state, plans, etc.) are served by Vite from disk instead of being silently round-tripped to the API server. File: `client/vite.config.js`.
- **Send to Video carries the source image's prompts.** Clicking "Send to Video" on an Image Gen result, gallery card, or lightbox — or the same action from Media History — now pre-populates the Video Gen prompt and negative prompt from the source image's metadata. Previously only the source image filename was passed, leaving the user to retype or paste the prompt to keep the same scene direction. The flow piggybacks on the existing `?sourceImageFile=` query-param hand-off: `?prompt=` and `?negativePrompt=` are added when the source has them, and `VideoGen.jsx` reads both on first render and re-syncs on subsequent navigations (mirroring the existing source-image effect). Files: `client/src/pages/ImageGen.jsx`, `client/src/pages/MediaHistory.jsx`, `client/src/pages/VideoGen.jsx`.
