# Release v1.44.1

Released: 2026-04-22

> **Note:** This version supersedes v1.44.0, which was deleted after shipping with a server-boot crash (see "Hotfix" below). All v1.44.0 content is included here.

## Hotfix (v1.44.1)

- **Server crashed on boot with `TypeError: Cannot read properties of undefined (reading 'catch')`** — `server/index.js:303` chained `.catch()` onto `agentActionExecutor.init()`, but that `init()` is synchronous and returns `undefined`. The `.catch` call threw at module load, crashing `portos-server` immediately after PM2 started it. Introduced during the v1.44.0 cycle and shipped because the release pipeline had no step that actually booted `server/index.js` — unit tests (vitest) import service modules in isolation and never execute the top-level initialization in `index.js`, and `npm run build` only builds the client. Dropped the `.catch` since `init()` is sync; added a new `npm run smoke` step (`scripts/smoke-boot.js`) that spawns `server/index.js` in a child process and fails if it exits within a 4 s boot window, so any similar top-level init crash blocks future releases instead of shipping.

---

## Added

- **Universal Xcode `deploy.sh` template — auto-detects schemes, defaults to every available platform** — the deploy script generator PortOS installs into Xcode projects (`generateDeployScript` in `server/services/xcodeScripts.js`) now produces a single universal template that auto-detects available schemes (trying both `TargetName`/`TargetName macOS`/`TargetName_Watch` and `TargetName_iOS`/`TargetName_macOS`/`TargetName_watchOS` candidates) so one template works across all naming conventions. The default (`./deploy.sh` with no flags) now fans out to every platform the project has a scheme for, keeping iOS + macOS + watchOS build numbers in lockstep on App Store Connect — previously it defaulted to iOS only and required `--all` to ship every platform. Serial uploads with a 60s inter-upload delay (via a shared helper that correctly sleeps between every successful upload in iOS↔macOS↔watchOS chains, not just before macOS). New **build-number rollback trap**: if any step after the build-number bump fails, the trap restores `project.yml` and `project.pbxproj` to their byte-exact pre-bump state, so a failed upload doesn't permanently consume a build number committed to main. `--transport DAV` on iOS/watchOS uploads for more reliable multipart handling. Shared `exportOptions.plist` across platforms (was duplicated three times). Explicit `--ios`/`--macos`/`--watch` flags hard-error on missing schemes; `--all` and the default silently skip missing ones. Existing per-project deploy.sh files are NEVER overwritten by `installScripts` — this new template only lands when installing into projects that don't already have one. `scaffoldXcode.js` README snippet updated to reflect the new defaults.

- **Opt-in HTTPS for managed apps via PortOS "Upgrade to TLS" action** — PortOS's own HTTPS+loopback-HTTP setup (previously inlined in `server/index.js`) is now a zero-dep helper module at `lib/tailscale-https.js` exporting `createTailscaleServers`, `watchCertReload`, and `hasTailscaleCert`. PortOS itself now uses the helper (dogfood). App schema gains an optional `tlsPort` field — when set, the Launch button on the app grid and detail view opens `https://<host>:<tlsPort>/` as the primary action (with plain HTTP demoted to a muted secondary button, and Dev UI still reachable when `devUiPort` is set). The PortOS baseline app auto-reflects `tlsPort: PORTS.API` whenever a cert is present on disk, so the Launch button targets the correct scheme out of the box. New app-manager action **Edit → Upgrade to TLS**: pick a TLS port (defaults to `uiPort + 1000`), PortOS copies `lib/tailscale-https.js` into the target app's repo at `<repoPath>/lib/tailscale-https.js` and records the `tlsPort` in `apps.json`; backend refuses overwrite unless the user clicks the "Overwrite existing" button that surfaces on collision. The response returns a preformatted snippet (with a copy button) showing how to wire the helper into the app's server entry — the wire-up is deliberately left to the user to avoid mis-patching arbitrary server files. The helper is zero-dep by design: apps that adopt it stay self-contained and runnable without PortOS. To share the single Tailscale Let's Encrypt cert across apps, each app points `CERT_DIR` at (or symlinks) `data/certs` in the PortOS install. New backend route `POST /api/apps/:id/upgrade-tls { tlsPort, force }` (409 `ALREADY_EXISTS` when a helper already lives in the target). Client launch URLs consolidated into `client/src/services/appUrls.js` (`getLaunchUrls`, `getPrimaryLaunchUrl`) so tile and detail views build URLs through one function. `apiCore.request()` now enriches thrown errors with `err.code` + `err.status` so callers can branch on server error codes.

- **Cyber Muse CoS avatar — GLB-backed holographic head** — new `muse` option in Settings → CoS → Appearance. Rather than assembling sphere primitives into a face (uncanny) or displacing a sphere point cloud (unreadable silhouette), the avatar loads a user-supplied `data/avatar/model.glb` through a new `/api/avatar/model.glb` route (`server/routes/avatar.js`, HEAD + GET, 404 when missing, `model/gltf-binary` when present) and renders it with a holographic material treatment: dark base + state-colored emissive glow that pulses per CoS state (sleeping/thinking/coding/investigating/ideating) and sync-bobs when the CoS is speaking. Bounding-box auto-fit means any model scale/origin works — drop in a ~4MB Ready Player Me export, a Sketchfab head bust, or a Mixamo-rigged character and it centers + sizes itself. When no GLB is present, renders a clean empty-state card pointing at the target path. Piggybacks on `useGLTF` (drei) and reuses the existing Sparkles/halo/ground-glow chrome. Zod enum for `avatarStyle` gained `'muse'` in `server/routes/cosStatusRoutes.js`.
- **Loopback HTTP mirror on port 5553 when HTTPS is active** — Tailscale's Let's Encrypt cert only covers `<machine>.<tailnet>.ts.net`, so `https://localhost:5555` always tripped a cert warning. When HTTPS is active the server now also spins up a plain HTTP listener bound to `127.0.0.1:5553` that attaches to the same Express app and Socket.IO instance — `http://localhost:5553` works without cert warnings and Chrome still treats it as a secure context (so getUserMedia / mic access continues to work). Override the port via `PORTOS_HTTP_PORT`. Documented in `docs/PORTS.md`.
- **Voice agent — universal accessibility-mode DOM driving** — the voice Chief-of-Staff can now navigate to any page and operate any control by voice. New `ui_navigate` tool with a 100+ entry named-page enum ("take me to tasks" / "chief of staff agents" / "calendar" all route correctly instead of defaulting to Daily Log). New accessibility layer: a client-side DOM indexer (`client/src/services/domIndex.js`) walks `<main>` for interactable elements (tabs, buttons, links, inputs, selects, textareas, checkboxes, radios), resolves labels via the standard aria → `<label for>` → wrapping label → text → placeholder → title chain, stamps stable `data-voice-ref` refs, and caps at 200 elements. `useVoiceUiSync` hook (debounced MutationObserver scoped to `<main>`, change-detection via content signature) pushes the index to the server whenever it actually changes. Server stores the index on per-socket state and — for UI-driving user turns (verb-gated by regex to keep the 80% of turns that don't need it cheap) — injects a compact page summary into the LLM's system prompt. New tools: `ui_click` (tab/button/link by label with optional kind hint), `ui_fill` (text inputs + textareas, uses React-safe native value setter so `onChange` fires), `ui_select` (dropdowns with fuzzy option matching), `ui_check` (checkboxes + radios), `ui_list_interactables` (deep-dive fallback). Misses return a helpful `available` list to the LLM so it can self-correct instead of silently failing. **In-turn chained flows**: after a ui_* action fires, the server pauses briefly for the client to push the post-action DOM index, then attaches the fresh summary to the tool result — so multi-step flows like "create a task named Foo with description Bar" can complete in a single turn (ui_click New Task → see the modal → ui_fill Name → ui_fill Description → ui_click Save).
- **Per-peer DNS override on the Instances page** — peer cards now expose an "edit host" control that lets you swap the raw Tailscale IP (`http://100.83.147.46:5555`) for a trusted HTTPS DNS name (`https://void.taile8179.ts.net:5555`). Once set, the server's probe, socket relay, sync orchestrator, and query proxy all route through `https://<host>` instead of `http://<ip>` — so app links in `PeerAppsList` also upgrade to HTTPS automatically. New `GET /api/instances/tailnet-suffix` endpoint shells out to the local `tailscale status --json` and returns the MagicDNS suffix + the full peer map, which the Instances UI uses to auto-suggest DNS names: match by Tailscale IP first (authoritative), fall back to `<peer.hostname>.<suffix>` composition. The UI's "use `<suggested>`" button applies the suggestion in one click. `updatePeer` now accepts an optional `host` field (validated as a DNS name; empty string clears); invalid hosts return 400 instead of silent no-ops. `addPeer` also accepts `host` for direct-DNS registration. Vite dev proxy auto-detects `data/certs/cert.pem` and flips its target to `https://localhost:5555` with `secure: false` when HTTPS is active, fixing "socket hang up" errors in dev mode. 44 tests (3 new) cover valid/invalid/cleared host handling.
- **HTTPS with auto-renewing Let's Encrypt cert via Tailscale (self-signed fallback)** — Chrome refuses `getUserMedia` (mic) access over plain HTTP on a `100.x.x.x` Tailscale IP because the origin isn't a secure context. New `scripts/setup-cert.js` prefers a real Let's Encrypt cert via `tailscale cert <machine>.<tailnet>.ts.net` (Tailscale owns `ts.net` and solves the DNS-01 challenge for you — browsers trust the cert with zero click-through) and drops it into `data/certs/{cert,key}.pem`. If the Tailscale CLI is missing, the machine isn't logged in, or HTTPS Certificates aren't enabled in the tailnet admin, the script falls back to a self-signed cert with SANs for `localhost`, `127.0.0.1`, and every non-internal IPv4 (including the Tailscale IP). `server/index.js` picks `https.createServer` whenever `data/certs/{cert,key}.pem` exist and falls back to HTTP with a warning otherwise. New `server/services/certRenewer.js` runs daily on the server process, calls `tailscale cert` (no-op unless the LE cert is <2/3 lifetime remaining), and hot-swaps the live HTTPS server's `SecureContext` with the renewed cert — no restart needed. `npm start` runs the cert script before PM2 boot so fresh installs come up with HTTPS out of the box; `npm run setup:cert --force` regenerates on demand. Socket.IO auto-upgrades to WSS (same-origin relative paths). Access via `https://<machine>.<tailnet>.ts.net:5555` to get the trusted cert; the Tailscale IP still works but will show a cert mismatch.
- **Blood Pressure tracking in MeatSpace → Body** — feature parity with the MortalLoom iOS/macOS companion app. New Blood Pressure card on the Body tab shows a systolic/diastolic trend chart (with AHA/ACC 2017 guideline reference lines), the latest reading, and its category + estimated life-expectancy impact. Readings are date-upserted so a HealthKit sync on the phone and a manual entry in PortOS on the same day merge into one row. Storage routes through the new `mlUpsertHealthMetricByDate` into MortalLoom's `healthMetrics[]` array when iCloud sync is enabled (same `bloodPressureSystolic`/`bloodPressureDiastolic` keys the Swift app writes), else falls back to `data/meatspace/health-metrics.json`. New endpoints: `GET /api/meatspace/blood-pressure`, `POST /api/meatspace/blood-pressure`. Apple Health XML ingest now maps `HKQuantityTypeIdentifierBloodPressureSystolic`/`Diastolic` → `blood_pressure_systolic`/`diastolic`, and those metrics are registered in the Core Vitals section of the Health tab so exported HK data shows trend lines there too. BP classification (normal / elevated / stage-1 / stage-2 / crisis) is ported from `CardioFitnessEngine.classifyBP` into `client/src/components/meatspace/bpClassification.js` so both sides report identical categories for the same reading.
- **Hide voice widget per-browser** — the floating voice widget can now be dismissed entirely by clicking the new eye-off icon (next to the mobile FAB or in the desktop controls bar). The preference persists in `localStorage` under `portos.voice.hidden`, so iPhones/Safari sessions where the mic APIs don't work can stay clean while the desktop keeps the widget. Re-enable via **Settings → Voice → "Show floating voice widget"**; a custom `portos:voice:visibility` DOM event syncs the change live without a reload.
- **Daily Log — voice-dictatable diary in the Brain section** — new `Brain → Daily Log` tab at `/brain/daily-log` for free-form typed or dictated daily entries. One entry per date, stored in `data/brain/journals.json` with per-segment source metadata (`text` vs `voice`). Five voice tools land in the CoS: `daily_log_open` ("open my daily log" — navigates + optionally starts dictation), `daily_log_start_dictation` / `daily_log_stop_dictation`, `daily_log_append` (one-shot append without dictation mode), and `daily_log_read` (reads the entry verbatim via TTS). Dictation mode short-circuits the LLM at the server — every voice utterance appends straight to the log until the user says "stop dictation" — so the CoS stops talking back while you ramble. Tools can now emit client-side side effects (`voice:navigate`, `voice:dictation`); the Voice widget listens and routes the browser + surfaces a "Dictating to Daily Log" chip. Optional Obsidian mirror: each entry auto-writes to `<vault>/<folder>/<YYYY-MM-DD>.md` when a vault is configured in the tab's settings panel (reuses the existing Notes vault registry), picking up iCloud backup for free. Entries flow through `brainMemoryBridge.js` (new `journals` brain type, `daily-log` category) so the CoS memory system vector-embeds each day and agents can semantically search across the log history. New routes: `GET/PUT/DELETE /api/brain/daily-log/:date`, `POST /api/brain/daily-log/:date/append`, `GET/PUT /api/brain/daily-log/settings`, `POST /api/brain/daily-log/sync-obsidian`.
- **Voice CoS tool expansion — round two** — nine more hands-free tools registered in `server/services/voice/tools.js`, further expanding the Voice CoS toolset:
  - `brain_list_recent` — "Read me my last five captures"; returns the newest inbox entries with date + text.
  - `meatspace_log_weight` — "Log my weight at 180"; accepts `lb` (default) or `kg`, auto-converts to lb before persisting, defaults to today.
  - `goal_list` — "What are my goals?"; lists active goals ordered by urgency with horizon, category, and progress percent.
  - `goal_update_progress` — "Bump my jacket goal to 40 percent"; fuzzy-matches the title (exact → substring → per-word token score), reports previous → new percent and flags ambiguous matches.
  - `goal_log_note` — "Add a note to my estate goal — found the property"; attaches a free-form progress entry to the matched goal without changing the percent.
  - `pm2_status` — "Is anything crashed?"; reports online/total plus any process in an unhealthy state with its restart count.
  - `pm2_restart` — "Restart portos-api"; restart-only by design (no kill/delete, per the shared-server CLAUDE.md rule), accepts a substring match on process name.
  - `feeds_digest` — "What's new in my feeds?"; returns total unread plus the top 5 newest items with title + feed name.
  - `time_now` — "What day is it?"; returns ISO + day of week + localized date/time. Included because LLMs don't know current time on their own and frequently hallucinate it.
  All new tools reuse existing PortOS services (`getInboxLog`, `addBodyEntry`, `getGoals`/`updateGoalProgress`/`addProgressEntry`, `listProcesses`/`restartApp`, `getItems`/`getFeeds`) and stay read/write-aligned with the web UI paths — no parallel data model. Settings → Voice copy updated to reflect the broader tool surface ("brain capture, goal updates, PM2 control, feed digests, time, and more").
- **Voice CoS tool expansion — round one** — four new hands-free tools registered in `server/services/voice/tools.js` on top of the existing `brain_capture`:
  - `brain_search` — substring-search the brain inbox ("what did I say about that book?"); returns up to 5 hits with date + text.
  - `meatspace_log_drink` — "I had a beer" / "log two glasses of Cabernet"; known categories (beer/wine/whiskey/shot/cocktail) auto-fill oz+ABV defaults, free-form names also work.
  - `meatspace_log_nicotine` — "I had a cigarette" / "two pouches"; presets for cigarette/vape/pouch.
  - `meatspace_summary_today` — "How am I doing today?" — reports today's drinks + nicotine against 7-day rolling averages.
  All new tools reuse existing services (`captureThought`, `getInboxLog`, `logDrink`, `logNicotine`, `getAlcoholSummary`, `getNicotineSummary`) so behavior stays aligned with the UI paths.
- **Curated Piper voice catalog** — Settings → Voice now surfaces a hand-picked set of Piper voices for comparison against Kokoro: British (Jenny Dioco, Cori-high, Southern English female), Scottish (Alba), Irish (VCTK p266 via `speakerId: 39`), and an American baseline (Amy). Picker shows accent + gender + quality note, and flags voices that aren't downloaded yet with a ⬇ marker — they fetch automatically on save via `scripts/setup-voice.sh`. New `server/services/voice/piper-voices.js` holds the catalog; `tts-piper.js` now passes `--speaker <id>` to the Piper CLI for multi-speaker models, and the health probe was swapped from a bogus HTTP check (CLI-mode piper has no server) to a real "binary + selected voice present" check. Default Piper voice flipped from `en_US-ryan-high` (male) to `en_GB-jenny_dioco-medium` (female).
- **Kokoro voice preview button in Settings → Voice** — new ▶ button next to the voice dropdown lets you audition any Kokoro (or Piper) voice without saving. Hits `POST /api/voice/test` with an optional `voice` override that temporarily swaps the voice for the test synth call — the saved config is untouched. Per-button spinner while rendering; button self-disables when another preview is in flight.
- **Human-readable Kokoro voice labels in Settings → Voice** — voice picker options now render as "American Female — Heart (A)" / "British Male — Fable (C)" instead of the raw `af_heart (Female) — A` form. Accent is derived from the voice's `language` field (`en-US` → American, `en-GB` → British) and the name after the underscore is title-cased. Trait icons (❤️, 🔥, 🎧) still prefix the label.
- **STT engine picker in Settings → Voice** — surfaces the existing `stt.engine` config option in the UI. Auto-disables the Web Speech API choice on browsers that don't expose `SpeechRecognition` (Firefox, older Safari) with a "not supported here" marker. Whisper-specific fields (model, endpoint, CoreML toggle) now hide when the engine is Web Speech, keeping the form focused.

## Changed

- **Default STT engine flipped from Whisper to Web Speech API** — fresh installs and the sample `data.sample/settings.json` now default to `stt.engine: 'web-speech'` (browser-native, zero-latency, no server process). `reconcile()` in `server/services/voice/bootstrap.js` now short-circuits when STT is Web Speech: stops any leftover `portos-whisper` PM2 app and skips binary/model verification and download so users who stick with the default don't pay for the whisper.cpp model or process. Health probes skip the whisper HTTP check in this mode and report a synthetic `web-speech: { ok: true, state: 'browser-native' }` status so the Settings page no longer shows whisper as "down" when it's intentionally off. Existing installs keep their saved `stt.engine` value — flip it via Settings → Voice → STT Engine.

- **Web Speech API STT mode for voice assistant** — new `stt.engine: 'web-speech'` config option uses the browser's native SpeechRecognition API for speech-to-text instead of whisper.cpp. Transcription runs entirely in-browser with zero network latency — final transcripts are sent via the existing `voice:text` path so no server changes needed. Hands-free toggle and VAD meter hidden in this mode (browser handles VAD natively). Interim transcripts appear live in the conversation panel. Enable via Settings → Voice → STT Engine.
- **Local voice Chief-of-Staff with hands-free VAD and opt-in tool calling** — new voice stack under `server/services/voice/` and `client/src/{services,components}/voice/` runs entirely on-device: Whisper (STT) + Kokoro or Piper (TTS) + LM Studio (LLM). Two capture modes: push-to-talk (click once to listen, click again to send) and **hands-free continuous mode** (default) that auto-submits on a ~600ms pause and lets you barge in over the bot's own voice. Hands-free is built around an AudioWorklet streaming raw PCM to a thin energy-based VAD — thresholds auto-calibrate to the user's ambient noise floor on startup, a pre-roll ring buffer avoids clipping the first syllable, a TTS echo-tail cooldown filters speaker→mic reverb, and a runaway-speech watchdog unjams VAD if silence never registers. Whisper requests now bias the decoder with a PortOS vocabulary prompt so proper nouns ("brain inbox", "Chief of Staff", "Tailscale") don't drift to homophones.
- **Configurable CoS personality for the voice assistant** — Settings → Voice exposes a personality editor (name, role, traits, speech style, custom prompt). The system prompt is composed live per turn from those fields plus the list of available tools. Toggle **Enable tools** (off by default; needs a tool-use-trained model like Qwen2.5-7B or Hermes-3) to let the CoS actually do things. First tool shipped: `brain_capture` — routes any "remember…", "add…", "note…" style request into the existing brain inbox via `captureThought()`. The pipeline runs up to 3 tool-call rounds per turn and streams text deltas to TTS between iterations so you hear the CoS narrate what it's doing.
- **Voice widget on every page** — floating conversation card (bottom-right) with live mic-level meter, stage indicator (`handsfree` / `capturing` / `thinking` / `speaking`), mode toggle, interrupt button, text-entry fallback, and scrolling transcript. Hotkey (default Space) toggles listening. Settings → Voice also gets STT/TTS engine pickers, Whisper model picker, Kokoro voice picker (dozens of voices: `af_*` female, `am_*` male, `bf_*`/`bm_*` British), CoreML encoder toggle, and one-click "Test voice".
- **MortalLoom iCloud sync** — new Settings tab (Settings → MortalLoom) lets PortOS share Goals and Meatspace data with the MortalLoom iOS/macOS app by reading from and writing to the app's iCloud ubiquity container (`~/Library/Mobile Documents/iCloud~net~shadowpuppet~MeatSpaceTracker/Documents/MortalLoom.json`). When enabled, PortOS treats the iCloud file as the source of truth for alcohol drinks, nicotine entries, blood tests, body composition, epigenetic tests, eye exams, and goals — and every add/update/remove flow mirrors back to it so edits on either side stay in sync. Non-destructive one-shot "Import from MortalLoom" button for adopting the iCloud data into local PortOS stores by id-dedup. New `server/services/mortalLoomStore.js` exposes tight primitives (`readStore`, `updateStore`, `mlPush/mlPatchById/mlRemoveById/mlReplace`, `mlArrayIfEnabled`, `mlIdAtDateIndex`) used by `meatspaceHealth`, `meatspaceAlcohol`, `meatspaceNicotine`, and `identity`.
- **MortalLoom promo banner on Meatspace + Goals pages** — dismissible banner (stored in `localStorage` as `mortalloom-banner-dismissed-v1`) linking to the App Store (`id6760883701`) and to the new Settings tab for iCloud sync configuration.

## Security

- **Dependency CVE sweep + SQL/JQL identifier escaping** — `/do:better` audit surfaced a handful of known-CVE dependency ranges and two string-interpolation sites. Upgraded `react-router-dom` 7.1.1 → 7.5.2 (GHSA-2w69-qvjg-hvjx + GHSA-8v8x-cx79-35w7 — XSS via `<Link to>` open redirect + SSR XSS). Added root `package.json` overrides to pin transitive deps against known issues: `socket.io-parser` >= 4.2.6 (GHSA-cqmj-92ph-6vvx binary-attachment OOM DoS), `path-to-regexp` >= 8.3.1 (GHSA-j3q9-mxjg-w52f ReDoS via Express 5's route matcher), `lodash` >= 4.17.21 (prototype pollution via `pm2`'s transitive dep), `basic-ftp` >= 5.2.3 (CRLF injection via `pm2`'s transitive dep), `follow-redirects` >= 1.15.12 (Authorization header forwarded on cross-origin redirects), and `brace-expansion` >= 2.0.2 (ReDoS). On the app code side, `server/routes/database.js` was building DDL like `CREATE USER ${pgUser} WITH PASSWORD '${pgPassword}'` via raw string interpolation — new `pgQuoteIdentifier()` / `pgEscapeString()` helpers double embedded quotes and wrap in standard SQL quoting before interpolation into `CREATE ROLE`/`CREATE DATABASE`/`DROP DATABASE` and the `SELECT ... WHERE datname = ...` lookup. `server/services/jira.js`'s "find epic" JQL query (`project = "${projectKey}" AND summary ~ "${query}"`) escapes `"` in both values before interpolation. Even on a single-user Tailscale deployment, misconfigured env vars (`PGUSER`/`PGDATABASE` containing metacharacters) or pasted Jira search terms with quotes would have broken the statement or altered semantics — now they pass through as literal strings.

## Fixed

- **Self-app "Launch" button opened an unreachable URL from the HTTP loopback mirror** — clicking Launch on `/apps/portos-default/overview` while browsing via `http://localhost:5553` built `http://localhost:5555` (hard-coded to the baseline app's `uiPort`), but port 5555 only speaks HTTPS when the Tailscale cert is active — so the tab opened to an empty reply. The tile grid (`AppTile.jsx`) and the detail view's Launch button (`AppDetailView.jsx`) now detect the self-app (`PORTOS_APP_ID`) and launch at `window.location.origin` so the scheme + port match the active session (HTTPS on 5555 or the HTTP loopback on 5553). Other apps still use `{protocol}//{hostname}:{uiPort}` as before.

- **Loopback HTTP port 5553 was invisible in the UI and hard-coded as `PORT - 2` magic in server code** — the port that hosts the plain-HTTP mirror of the API wasn't declared alongside the other PortOS ports, so the Overview tab listed `portos-server:5555` with no hint that `:5553` was also in use (and for what). `ecosystem.config.cjs` now declares `PORTS.API_LOCAL = 5553` and wires it into the `portos-server` env as `PORTOS_HTTP_PORT`. `server/lib/ports.js` mirrors the constant so server code can reference it by name; `server/index.js` uses `PORTS.API_LOCAL` as the loopback fallback instead of `Number(PORT) - 2`. `buildPortosApp()` lists `api-local: 5553` on the portos-server process entry, so Overview now renders `portos-server (api:5555, api-local:5553)` — the existing `processes[].ports` iterator picked it up for free.

- **Lifestyle tab — biological sex is now editable and round-trips to MortalLoom iCloud sync** — `/meatspace/lifestyle` previously showed Biological Sex as a read-only text label, so users had no way to correct an auto-genome-detected sex or set it manually when no genome was uploaded. The Profile card now renders a Male/Female/Unknown segmented picker in the same style as the other lifestyle buttons, and Save now uses `PUT /api/meatspace/config` (instead of `/lifestyle`) so sex, sexSource, and the questionnaire write in one round-trip. `sexSource` is stamped to `'questionnaire'` whenever the user sets it manually so the next `getConfig()` call doesn't silently overwrite it via the auto-detect-from-genome code path. **MortalLoom iCloud parity**: `getConfig()` now treats `store.profile` in the iCloud MortalLoom.json as authoritative when sync is enabled — `biologicalSex`, `birthDate`, and the `lifestyle` subset of fields (smoking/exercise/sleep/diet/stress/BMI — the keys MortalLoom's Swift `LifestyleData` struct defines) get merged into the returned config so edits made in the iOS/macOS app show up in PortOS without a manual import. Writes mirror the other direction too: `updateConfig()`, `updateLifestyle()`, and `updateBirthDate()` now patch `store.profile.biologicalSex` / `store.profile.lifestyle` / `store.profile.birthDate` via a new `mlPatchProfileIfEnabled()` helper, which deep-merges nested objects so lifestyle can be patched field-by-field without clobbering siblings. PortOS-only lifestyle fields (`alcoholDrinksPerDay`, `chronicConditions`) are filtered out of the MortalLoom write via a `MORTALLOOM_LIFESTYLE_KEYS` allowlist so the store only gains keys MortalLoom's Swift decoder accepts. `configSchema.sex` now allows `null` so clearing the sex from the UI validates.

- **Genome-Health cards mislabeled longevity/protective markers as "Elevated Risk Marker"** — the `/insights/genome-health` tab tagged every `concern` status with the risk language `CONFIDENCE_FROM_STATUS` produced ("Elevated Risk Marker", "Significant Risk Marker"), which was wrong for protective-variant markers like FOXO3A/rs2802292 where `concern` just means "doesn't carry the longevity G allele" — not an elevated risk, just a typical population outcome. The card also hid the user's genotype so you couldn't tell whether you carried the marker at all. Fix: added a polarity inference on `CuratedMarker` — rules containing `major_concern` describe a risk marker, rules going beneficial→typical→concern describe a protective marker — and two separate confidence tables (`CONFIDENCE_PROTECTIVE` = "Beneficial Variant / Partial Variant / No Benefit Variant" in neutral grays, `CONFIDENCE_RISK` = "No Risk Variant / Typical / Carrier / Risk Variant" in green→yellow→orange→red). The server now stamps each enriched marker with its `polarity` and `genotype`; the client card shows a "Your result" row with the genotype rendered in a color-coded pill (neutral gray when a protective marker's status is just "no variant"), surfaces a subtle "protective variant" hint with a tooltip explaining the framing, and prefixes the implication text with "What this means:". Added a new `neutral` level to `ConfidenceBadge` for the no-variant-on-protective-marker case. MortalLoom parity: `CuratedMarker` gained the same inferred `polarity` extension and `GenomeView.swift`'s `colorForStatus`/`iconForStatus`/`statusLabel` helpers now accept a polarity argument and return protective-aware labels ("Beneficial Variant" / "No Benefit Variant" in `.secondary` gray with a `minus.circle.fill` icon) so a FOXO3A T/T result no longer shows an orange warning triangle. Scanned all 116 curated markers — the inference holds: longevity/tumor_suppression/cognitive markers use protective polarity, disease-risk markers (iron, cardiovascular, autoimmune) use risk polarity.

- **Voice agent looped repeating itself because Web Speech STT was transcribing the bot's own TTS as user turns** — with the default `stt.engine: 'web-speech'`, the browser's SpeechRecognition kept listening while TTS played through the speaker, so short fragments of the reply ("specific block", "activity", "or") came back in as new user finals and `sendText()` routed them to the LLM — which re-ran the last tool and produced another TTS reply, which echoed again. Unlike continuous-VAD mode, Web Speech mode had NO echo rejection. `voiceClient.js` now tracks recently-spoken TTS sentences (8s ring buffer, normalized) and drops Web Speech finals on two gates: (1) time-based — TTS currently playing or within the existing echo-tail cooldown, or (2) content-based — the final is a substring of a recent TTS sentence. Late echoes that arrive after the time window (slow STT finalize, long reverb) still get rejected by the content gate. Dropped echoes log `🔇 [voice] dropping TTS echo "..."` so the pattern is visible if it recurs. Barge-in via voice while TTS plays is now disabled in Web Speech mode (the mic has no way to distinguish user voice from speaker bleed) — users can still interrupt via the dedicated interrupt button that appears while the bot is speaking.

- **Voice agent said "I opened that" / "Added to your inbox" but nothing actually happened** — small tool-use models (Qwen3-4B class) occasionally narrate an action as their spoken reply without ever issuing the matching `tool_call`. The system-prompt biasing (enumerated trigger phrases, NEVER-echo directives) catches most of these, but leaks remain on long tool lists and fuzzy phrasings. The user heard a confident confirmation but no navigation/capture/fill happened. `pipeline.js` now detects the pattern after the turn completes — when no tool ran, the final reply is non-empty, and the reply matches a narrow action-claim regex ("I opened", "I've saved", "Navigating to", "Adding that", etc.) — and emits a `voice:error` toast ("I said I'd do that but didn't actually call the tool. Try rephrasing — e.g. 'open my daily log' or 'go to tasks'.") so the user knows the action didn't land and can rephrase. The regex is intentionally narrow on first-person past/progressive action-claim phrasings to avoid false-positives on conversational replies that mention the same verbs ("You saved that yesterday", "Your daily log is a good habit"). Also logs `🎙️ [turnId] narrate-without-call user="..." reply="..."` so we can grep server logs for recurrences. New `detectNarrationWithoutCall` helper is exported for unit testing; 6 new tests cover first-person past, progressive tense, confirmed-by-tool-run (no flag), empty reply (no flag), conversational false-negative cases, and missing-input tolerance.

- **Voice agent wrote to the brain inbox instead of filling the target form field, then captured its own TTS summary as a second brain entry** — two coupled bugs. (1) `brain_capture` was always-on in the per-turn tool list, so on UI form-fill turns ("fill description with X", "type Y in the title", "put Z in the body") the LLM saw both `ui_fill` and `brain_capture`. Because `brain_capture`'s description emphasizes "note/save/remember/jot" — words that overlap with field content — the model frequently picked capture over fill. Moved `brain_capture` into the `brain` group, expanded the brain intent regex to still catch natural phrasings ("remember to buy milk", "jot down", "note that…", "save this/that", "file this", "add this to my brain inbox"), and added a `UI_FILL_INTENT_RE` that detects form-fill verbs near a field-word ("description/name/title/subject/body/content/field/input/textarea/form/label/placeholder/caption") and forcibly suppresses `brain_capture` + `daily_log_append` for that turn (also force-activates the `ui` group so `ui_fill` reaches the LLM even when the verb wasn't already in the UI regex, e.g. "put Y in the body"). System prompt gains a CRITICAL section that explicitly forbids capture tools on field-fill turns regardless of content words. (2) In hands-free mode the VAD used `bargeInMul=1.15` and `ttsTailMs=300ms` — too permissive for typical speaker→mic coupling. The bot's TTS output crossed the raised threshold, VAD transitioned to "speaking", captured the bot's own voice, submitted it as a new `voice:turn`, and the LLM processed its own paraphrased summary as a user command — creating a duplicate brain entry. Raised `bargeInMul` to 2.5, extended `ttsTailMs` to 700ms for typical room reverb, and added `bargeInOnsetConfirmMs=220` so during TTS playback + echo tail the VAD requires sustained signal (not a brief echo spike) before confirming speech-onset. Deliberate barge-in still works (sustained speech above the raised threshold). 10 new tests cover the tool-filter behavior (fill patterns drop brain_capture; natural capture phrases keep it; UI-only turns exclude brain_capture; expanded brain regex matches "remember"/"jot"/"note that").

- **Voice agent lost context after `ui_navigate`, couldn't chain "go to tasks and create a new one"** — the tool-calling loop only paused for a client UI-refresh push when a `ui:*` side effect fired (`ui_click`, `ui_fill`, etc.). `ui_navigate` emits a `navigate` side effect, so the next LLM iteration ran against the old page's UI summary — it couldn't see the new page and effectively ended the turn without acting on the user's next step. `pipeline.js` now treats `navigate` as UI-mutating too and awaits the post-navigation `voice:ui:index` push (1500ms timeout since a full React route change + lazy-loaded bundle is slower than a ~120ms post-click DOM mutation). The fresh page summary is attached to the tool result so the LLM can chain follow-up `ui_click`/`ui_fill`/etc. in the same turn. System prompt updated to include `ui_navigate` in the CHAINED UI FLOWS guidance with a worked "go to tasks and add a task" example.

- **Daily Log unusable on mobile** — the tab split into a fixed 256px history sidebar plus a desktop-only toolbar (back/forward/date/today/read-back/dictate/save/delete all on one non-wrapping row), so on phones the history ate most of the screen and the action buttons overflowed offscreen. Sidebar now collapses into a slide-in drawer (hamburger in the toolbar opens it, tap outside to dismiss), toolbar wraps with the full date label breaking to its own row, and action-button text hides below `sm` leaving icon-only buttons with tooltips + aria-labels. All interactive targets bumped to 40px minimum height per the mobile touch-target rule, banner/form paddings tightened for narrow viewports.

- **Server crash on startup when `kokoro-js` not installed** — `tts-kokoro.js` used a top-level `import` of `kokoro-js`, which crashed the entire server module graph if the package was missing. Moved to a dynamic `import()` inside `ensureModel()` so the server starts normally and Kokoro TTS fails gracefully only at synthesis time.

- **Daily Log (and schedulers) could land on tomorrow's date for a UTC-configured server** — `ecosystem.config.cjs` forces `TZ=UTC` on the server process, so `getUserTimezone()`'s fallback (`Intl.DateTimeFormat().resolvedOptions().timeZone`) resolved to `UTC` whenever `settings.timezone` was unset. After 5pm PDT the daily-log "today" key flipped to tomorrow, creating entries like `2026-04-20` on a Sunday evening. `App.jsx` now bootstraps `settings.timezone` from the browser's IANA zone on first load if the setting is empty, so remote/VPN clients self-heal without visiting Settings → General. Skips the write if the browser itself resolves to UTC (avoids persisting a useless value).

- **Voice preload spawned a duplicate model instance on every server restart** — `lms load <model>` is *not* idempotent in practice; calling it on an already-loaded model spawns a SECOND instance in LM Studio (we observed 3 simultaneous copies of `qwen/qwen3-4b-2507` in one user's setup, eating 7+ GB extra). When VRAM was nearly full, the duplicate load failed with "Model loading was stopped due to insufficient system resources" and the preload reported as failed even though the original instance was fine. `preloadModel` now calls `lms ps --json`, parses `modelKey`s from the response, and skips the `lms load` if the chosen model is already loaded. Logs `🎙️ voice: <model> already loaded — skipping preload` when this fires.

- **Daily Log "let's make a daily log" opened the page but didn't start dictation, leaving the user stuck routing each utterance through the LLM** — the assistant then narrated `Appending to daily log: "..."` without actually calling `daily_log_append`, so nothing got written. Two changes: (1) `daily_log_open` tool description now says `startDictation` defaults to true for any create/write-intent phrasing ("make"/"start"/"new"/"create"/"dictate"/"record"/"talk into"/"log something"), with `startDictation=false` reserved for explicit view-only verbs ("show me"/"open"/"go to" without any write verb). (2) System prompt enumerates the create-intent phrases that should map to `daily_log_open(startDictation=true)` and explicitly tells the model to STAY QUIET after the call so the dictation system handles every following utterance without an LLM in the loop. Live-tested on qwen3-4b: 4/4 create-intent phrasings now route to dictation; 1/1 view-intent phrasing routes to navigation only.

- **Daily Log dictation silently flipped off mid-session after a brief network hiccup** — Socket.IO auto-reconnects on transient drops (idle tabs, sleeping laptops, flaky Tailscale), but each new connection spawns a fresh `state.dictation = { enabled: false }` on the server. The client UI still showed "Dictating" because no event told it otherwise, so subsequent utterances were routed to the LLM (Conversation panel) instead of appended to the journal. `voiceClient.js` now caches the last `setDictation` request and re-emits it on every socket `connect` event so the server's per-socket state always matches what the UI thinks it requested. Idempotent — re-emitting `enabled: true` for the same date is a no-op when state already matches.

- **Daily Log "Dictate" button enabled server-side dictation but the mic stayed off** — clicking *Dictate* set the server into dictation mode (banner shown, "Dictating to Daily Log" pill visible) but the user's microphone was never started, so utterances weren't captured and the entry stayed empty. The toggle only emitted `voice:dictation:set`; nothing told the VoiceWidget to also start listening. Now the widget's `voice:dictation` listener auto-calls `handleStart()` when dictation enables (Web Speech / hands-free / push-to-talk path is chosen by the same logic the mic button uses) and `handleStop()` when it disables. Routed through refs so the listener always picks up the latest engine settings without rebinding the socket subscription on every prop change.

- **Voice agent narrated navigation requests instead of calling the navigation tool** — small models (qwen3-4b) frequently echo back "open my daily log" as their *spoken reply* instead of issuing the `daily_log_open` tool call. The system prompt only listed capture verbs (save/add/remember/note); it didn't cover navigation verbs (open/go to/take me to) or fuzzy daily-log phrasings ("let's make a daily log", "I want to make a log entry"). System prompt now enumerates trigger phrases for navigation, dictation, capture, time, goals, and pm2 tools, and explicitly instructs the model to never echo or paraphrase the action as its reply without first calling the tool. Live-tested against qwen3-4b — three of five tested phrasings that previously echoed now correctly hoist to `daily_log_open` (and the rest land within one retry).

- **Daily Log missing from Brain subnav** — `/brain/daily-log` had no sidebar link under the Brain section, so users had to type the URL or use voice navigation to find it. Added between Config and Digest in alphabetical order with a `BookOpen` icon.

- **Voice agent painfully slow when `model: 'auto'` picked a reasoning or oversized model** — `auto` was selecting `mistralai/ministral-3-14b-reasoning` (or any reasoning-tagged tool-capable model with the smallest `B` suffix), which spends 10–30s on internal `<think>` tokens before saying a word. Combined with the 70B Llama loaded in LM Studio eating the user's VRAM and no fast model installed, the first reply could take minutes. Fixes:
  - `resolveModel` now sorts by `(non-reasoning first, then size)` instead of size alone, with a new `isReasoningModel` detector that flags `reasoning`/`r1`/`qwq`/`thinking`/`o1`/`deepseek-r1` patterns.
  - When a reasoning model is the only tool-capable option, `streamChat` injects the disable directives that LM Studio actually honors per family — `/no_think` appended to the last system message (Qwen3), `chat_template_kwargs: { thinking: false, enable_thinking: false }` (Granite/vLLM), and `reasoning_effort: 'minimal'` (generic OpenAI shape). Unknown fields are ignored by other servers.
  - Streaming stripper extended to drop `<think>`/`<thinking>`/`<reasoning>` blocks from `onDelta` (so reasoning text never reaches TTS) and a post-stream regex strips them from the canonical reply text (so they don't pollute conversation history).
  - `ensureToolCapableModel` now requires the smallest **non-reasoning ≤10 B** tool-capable model — a user with only `mistral-small-24B` installed gets a small fast model downloaded alongside instead of being stuck thrashing VRAM.
  - Auto-install chain bumped to current LM Studio Hub catalog: `qwen/qwen3-4b-2507` (4B non-thinking, 2.28 GB MLX) → `qwen/qwen3.5-9b` → `lmstudio-community/Qwen2.5-7B-Instruct-GGUF` → `meta-llama/Llama-3.1-8B-Instruct`. Snapshot-diff before/after install so success is detected by "a new fast model appeared", not "any tool-capable model exists" (which previously misattributed success when install failed).
  - `preloadModel` calls `lms load <chosen>` after reconcile so the first voice turn after server restart doesn't pay a 5–30 s cold-load. Mirrors the same `(non-reasoning, smallest)` preference order.
  - Default `max_tokens` for voice dropped from 250 → 180 — voice replies are short by design and the lower cap shaves stop-token wait time.
  - Result on test machine: TTFT for a warm short reply went from ~234 s → ~930 ms (~215× faster).

- **Voice agent silent after every question when `model: 'auto'`** — the Chief-of-Staff transcribed the user's speech but never replied (server log showed `ttft=—ms · tts=0ms (0 sentences)` with no error). Two root causes: (1) `'auto'` resolved to whichever LM Studio model had the smallest `B` suffix in its id — on IBM Granite 3.x installs that is Granite itself, which does not emit OpenAI-format `delta.tool_calls`; it emits its own `<tool_call>[{...}]</tool_call>` (and sometimes `<tool_request>[{...}]`, often unclosed) inline in `delta.content`. With the full 19-tool schema attached, Granite silently bailed with a single empty SSE chunk and closed the stream. (2) The pipeline only logged a turn-summary at the END, so a 60s reasoning loop looked like the server was dead. Fixes:
  - `resolveModel` now prefers a curated tool-use allowlist (Qwen2.5-Instruct, Hermes-3, Mistral-Small, Devstral, Ministral, Llama-3.1+-Instruct, Command-R, Functionary) over size-ranked picks when `tools.enabled`, and steps past known-incompatible families (Granite, tiny non-instruct Gemmas).
  - `extractInlineToolCalls` parses both `<tool_call>` and `<tool_request>` spellings in either closed or unclosed form and hoists them into structured `toolCalls`, with a balanced-bracket JSON scanner that survives `]` characters inside string arguments. A matching streaming stripper withholds those fragments from `onDelta` so raw JSON never reaches TTS.
  - `ensureToolCapableModel` auto-installs a default tool-capable model the first time voice reconciles with no compatible model present — shells out to `lms get -y lmstudio-community/Qwen2.5-7B-Instruct-GGUF` (overridable via `PORTOS_VOICE_DEFAULT_TOOL_MODEL`). Runs fire-and-forget so whisper/TTS come up immediately.
  - Per-event timing logs with a turn correlation id: `turn.start`, `stt.done`, `lmstudio.resolve`, `lmstudio.headers`, `llm.start`, `llm.ttft`, `llm.done`, `tool.start`/`tool.done`/`tool.fail`, `tts.start`/`tts.done`, `turn.summary`. Makes it obvious whether latency is in cold-load, reasoning, tool execution, or TTS.
  - Empty-output guard — when the LLM produces no text and no tool runs, the server now emits `voice:error` with a specific model-switch hint (the existing VoiceWidget toast surfaces it) instead of going silent.

- **Voice widget covering page content on mobile** — the floating voice input bar (text field, hands-free toggle, mic button) was fixed at the bottom of the screen and spanned the full width on mobile, blocking form buttons (Cancel/Save) and navigation. On mobile (<768px) the widget now collapses to a single compact mic FAB in the bottom-right corner; tapping it expands the full controls and starts listening in one tap. An X button dismisses back to the FAB. Desktop behavior is unchanged.

- **Cmd prompt windows flashing on Windows** — `fetchJlist` in `pm2.js` was spawning a `node pm2.js jlist` subprocess every 400ms+ during app-status polling. Even with `windowsHide: true`, Windows briefly flashes the console for spawned Node.js processes on some configurations. Fixed by switching to the PM2 Node.js API (`pm2.list()` via IPC) for the default PM2_HOME case, eliminating subprocess spawning entirely. The CLI path is kept only for custom `pm2Home` (managed external apps).
- **Digital Twin build errors — missing Soul-prefixed API exports** — components used `api.createSoulDocument`, `api.runSoulTests`, etc. but `apiDigitalTwin.js` only exported `DigitalTwin`-prefixed names. Added 10 Soul aliases so the build resolves all imports.

- **POST drill scoring paired every answer to the first prompt** — `llmResponseSchema` in `postValidation.js` didn't list `questionIndex`, so Zod's default strip mode dropped the field on its way to the scorer. The scorer's `r.questionIndex ?? i` fallback then resolved to the response-array index — always 0 for the single-response submits the trainer makes — and every answer was evaluated against the FIRST prompt of the drill. Affected bridge-word, idiom-twist, double-meaning, and every other LLM-scored POST drill (the v1.43.0 client-side fix wired up `questionIndex` on the request but it never reached the scorer). Schema now declares `questionIndex` and a regression test in `postValidation.test.js` covers the round-trip.
- **Compound chain rejected the natural shorthand** — typing just the *other half* of a compound (e.g., `hose`, `pit`, `work` for the root `fire`) was scored as invalid because the local scorer required the user's input to literally include the root word. Players had to type `firehose` / `firepit` / `firework` in full, which slowed them down and produced misleading "0 valid compounds" scores. Scorer now accepts both the full compound and the half — bare root word still rejected, and the "you could also have said" suggestions correctly suppress examples the user already covered via either form. UI placeholder updated to "Type the full compound or just the other half...".
- **Brain inbox classification with Gemini CLI was slow** — the provider had no default model, so `gemini` fell back to `gemini-2.5-pro` (a thinking model) and was also invoked without `--prompt`, forcing interactive-mode detection on every classify call. `brain.js callAI()` now sends gemini-cli prompts via `--prompt`, passes `--output-format text`, and defaults to `gemini-2.5-flash-lite` (thinking disabled) when no model is set. Sample providers config populates `models`/`defaultModel`/`lightModel`/`mediumModel`/`heavyModel` for gemini-cli so new installs pick up the fast model automatically.




## Full Changelog

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