# Release v1.47.0

Released: 2026-04-29

## Full Changelog

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


## Changed

- **CyberCity — Phase 1 operational legibility.** Turned the city from a decorative scene into an interactive systems map you can actually run PortOS from. The cryptic weather glyph in the HUD ("//", "%%", "||") is replaced by a plain CPU / MEM / DISK card with a single colored sentinel dot (cyan→amber→red), backed by `/api/system/health/details` polled every 15s; atmospheric weather still drives the sky, but the readout you read is just numbers. Every HUD stat is now clickable: AGENTS jumps to `/cos`, REVIEW to `/review`, NODES to `/instances`, TASKS / STREAK to `/cos`, STOPPED / ARCHIVED to `/apps`, the health row to `/`. Replaced the right-side activity-log panel with a tabbed Intel pane: an `ATTENTION` tab (default) ranks stopped apps, errored PM2 processes, system warnings, pending reviews, federation peers offline, failed agents, and unread notifications by severity — every item is a button that navigates to the relevant page; the live event log is still there as the `ACTIVITY` tab. Added a top-center filter bar with status chips (All / Online / Stopped / Errored / Agent) plus a `/`-key search overlay; non-matching buildings dim to 25% opacity in the 3D scene so you keep spatial context while finding the one you want, and Enter on the search bar jumps to the first match's app page. Documentation: rewrote `docs/features/cybercity-v2.md` to unify the operational-legibility track with the existing atmospheric roadmap into a single 3-phase plan, and shifted the design rule from "read-only" to "interactive systems map" — explicit user-initiated actions (restart, deploy, jump to logs) are now allowed, but the city still can't mutate goals/tasks/notes/memory. Files: `docs/features/cybercity-v2.md`, `PLAN.md`, `client/src/hooks/useCityData.js`, `client/src/pages/CyberCity.jsx`, `client/src/components/city/{CityHud.jsx,CityScene.jsx,BuildingCluster.jsx,Borough.jsx,Building.jsx,CityIntelPane.jsx,CityFilterBar.jsx}` (the last two new). Removed: `client/src/components/city/CityActivityLog.jsx` (its content is now a tab inside `CityIntelPane`).

- **PortOS brand mark + favicon refresh.** Replaced the old portal-ring SVG logo with the new PortOS brand image (`client/public/portos-logo.png`, 512×512). The `Logo` component now renders a `<PortOSMark>` `<img>` so the full-color PNG ships verbatim — the previous `currentColor` SVG variant (and the unused `LogoGrid` / `LogoAnchor` / `LogoHex` / `LogoP` alternates) are removed. `AppIcon`'s `portos` glyph delegates to the same mark so the bookmarked-app tile matches the chrome. Sidebar and mobile-header logos go from `size=18/24` to `22/28` and drop the `text-port-accent` tint now that the artwork is its own color. Favicon, apple-touch-icon, and 192/512 PWA icons regenerated from the new artwork; `favicon.svg` simplified to embed `/portos-logo.png` for SVG-favicon-only browsers; `client/index.html` switches the primary `rel="icon"` to the PNG and updates `theme-color` to `#070B12` (matched in `manifest.json`'s `theme_color` + `background_color`). Added `docs/media/portos-logo.png` and embedded it in the README header. `.gitignore` keeps the global `*.png` ignore but allowlists the two committed brand PNGs so future contributors don't have to remember `git add -f`. Files: `client/src/components/Logo.jsx`, `client/src/components/AppIcon.jsx`, `client/src/components/Layout.jsx`, `client/index.html`, `client/public/{favicon.svg,favicon.ico,apple-touch-icon.png,icon-192.png,icon-512.png,manifest.json,portos-logo.png}`, `docs/media/portos-logo.png`, `README.md`, `.gitignore`.

## Added

- **`/system-health` — actionable detail page for the System Health widget.** The widget's "Details →" used to jump to `/apps`, which has nothing to do with what the alert is telling you. New page shows: per-resource cards (Memory / CPU / Disk) with the threshold bands annotated underneath ("warn 85% · critical 95%"), top-10 PM2 processes sorted by memory with status dot + CPU% + crash-loop badge, deep links to `/devtools/processes`, `/apps`, and `/data` for drill-down, and an inline Alert Thresholds editor (memory warn/critical, disk warn/critical) that persists to `data/settings.json` under `health` via a new `PUT /api/system/health/thresholds` endpoint. Reachable via Cmd+K (`system-health` / `memory-usage` / `disk-usage` / `cpu-usage`) and registered in `NAV_COMMANDS`. The widget's Details link now points here. Files: `client/src/pages/SystemHealthPage.jsx` (new), `client/src/App.jsx`, `client/src/components/SystemHealthWidget.jsx`, `client/src/services/apiSystem.js`, `server/routes/systemHealth.js`, `server/lib/navManifest.js`.

## Fixed

- **System Health alert thresholds were tuned for a server, not a dev machine.** Defaults were warn=75% / critical=90% for memory and warn=85% / critical=95% for disk. On a real workstation that runs LLMs (mflux, kokoro, browser, etc.) plus normal app surface, memory sits in the 78-85% band as a baseline and big SSDs commonly cross 90% before a real problem — so the widget reported "Critical · Memory usage above 75% · Disk usage at or above 95%" perpetually. Bumped defaults to warn=85% / critical=95% memory, warn=90% / critical=98% disk; both are now also user-overridable from `/system-health` (persisted under `settings.health`). The threshold values flow into the warning text too — "Memory usage at or above 85%" instead of the hard-coded "above 75%". Files: `server/routes/systemHealth.js`, `server/routes/health.test.js`.

- **Review Hub: CoS-spawned tasks lingered as "pending" forever.** The review service emitted a `cos`-type item every time `cosEvents.emit('task:ready', ...)` fired, but nothing closed the loop when the agent actually finished — so the user saw a permanent backlog of "CoS Actions" they had to manually tick off, even for tasks the agent completed unattended. `server/services/review.js` now also listens for `agent:completed` and auto-completes any pending review item whose `metadata.referenceId` matches the agent's `taskId` when `result.success` is truthy (failures stay pending so the user still sees them). Mirrors the existing `memory:approval-needed` → `memory:approved`/`rejected` dismiss pattern. Refactored the helper into a single `updateStatusByReferenceId(id, status)` so dismiss and complete share the loop. Added two tests against the registered `agent:completed` listener (success → complete; failure → no-op). Files: `server/services/review.js`, `server/services/review.test.js`.

- **Review Hub: only had a "Mark All Read" (dismiss) bulk action.** Added a green "Complete All" button next to the renamed "Dismiss All" so the user can clear a queue of finished items as completed (correct status for things that were actually done) instead of dismissing them (which means "ignored"). File: `client/src/pages/Review.jsx`.

- **System Health: "N total restarts" warning treated developer-driven `pm2 restart`s as a problem.** `server/routes/systemHealth.js` was warning whenever `processStats.totalRestarts > 10`, summed from PM2's `restart_time` — a cumulative counter that increments equally for crash loops AND for routine `pm2 restart` calls during development/deploys. So a perfectly healthy PortOS instance with months of feature work would surface "97 total restarts" as a yellow warning forever. Switched the warning to PM2's `unstable_restarts`, which only ticks when a process exits inside its `min_uptime` window (i.e., a real crash loop). Exposed `unstableRestarts` from `pm2.js` `getProcessSummary` / `listProcesses`, added it (and a `crashingProcesses` name list) to the `processStats` payload, and rewrote the warning message to "N crash-loop restart(s) (proc-name)". Cumulative `totalRestarts` is still in the payload for telemetry but no longer drives the alert. Two regression tests: a 97-restart healthy process produces 0 warnings; a 3-unstable-restart process produces a "crash-loop" warning naming the process. Files: `server/services/pm2.js`, `server/routes/systemHealth.js`, `server/routes/health.test.js`.

- **System Health: "High memory usage" alert fired on every box because `os.freemem()` doesn't mean "free" on macOS.** `server/routes/systemHealth.js` and `server/services/proactiveAlerts.js` both calculated memory pressure as `(os.totalmem() - os.freemem()) / os.totalmem()`. On macOS, `os.freemem()` returns *only* `Pages free` from the vm_stat sense — it counts the file cache (inactive + speculative + purgeable + cached file-backed pages, often 8–10 GB) as "used" even though the kernel will reclaim those instantly under pressure. So a healthy 48 GB Mac with Activity Monitor reading ~38 GB / 81% used would surface in PortOS as 47 GB / 98% used and trigger the critical "Memory usage above 90%" alert. New `server/lib/memoryStats.js` helper parses `vm_stat` on macOS and computes `used = (anonymous_pages - purgeable + wired + compressor_occupied) × pageSize`, which matches Activity Monitor's "Memory Used" figure (within a percent or two of natural drift). Linux uses `MemAvailable` from `/proc/meminfo`; other platforms fall back to the previous `os.totalmem - os.freemem`. Wired into both the `/api/system/health/details` endpoint (powering the CyberCity HUD's CPU/MEM/DISK card) and the proactive-alerts critical-memory check. 3 unit tests cover the macOS happy path, the over-count clamp, and the vm_stat-failure fallback. Files: `server/lib/memoryStats.js`, `server/lib/memoryStats.test.js`, `server/routes/systemHealth.js`, `server/services/proactiveAlerts.js`.

- **Review Hub: "Dismiss All" / "Complete All" only marked ~10 items per click.** The first cut of those buttons fanned N parallel `POST /api/review/items/:id/{dismiss,complete}` requests at the server. Each handler did its own `loadItems → mutate → atomicWrite(items)` cycle, so concurrent requests read overlapping snapshots of `data/review/items.json` and the last writer overwrote everyone else's status changes — the user had to keep clicking until every item settled. Added a single `POST /api/review/items/bulk-status` endpoint that does one read-modify-write for `{ status, ids? }` (omit `ids` to target every pending item), and switched the client to call it once instead of N times. Side-effect emits (`item:updated`) now fire after the save, not inside the mutation loop, so socket clients can't observe state ahead of disk. Files: `server/services/review.js`, `server/routes/review.js`, `server/services/review.test.js`, `client/src/services/apiReview.js`, `client/src/pages/Review.jsx`.

- **Flux i2i: portrait iPhone photos came out rotated 90°.** Modern browsers honor the EXIF Orientation tag when rendering `<img>`, so the upload thumbnail looked right — but mflux/PIL reads raw pixel data and ignores EXIF, so a portrait iPhone photo (Orientation=6 or 8) landed sideways in the generated output. The init image picker now passes the file through `createImageBitmap({ imageOrientation: 'from-image' })` and re-encodes via canvas before upload, baking the rotation into the actual pixels and stripping the EXIF tag. Any image decoder downstream now sees the same orientation the browser displayed. File: `client/src/pages/ImageGen.jsx`.

- **Multipart upload server crash: `Cannot read properties of null (reading 'write')`.** Streaming multipart parser (`server/lib/multipart.js`) crashed with an uncaught `TypeError` when the request stream's `'end'` event fired while a file part's `ws.end()` flush was still in flight. After consuming a part boundary, `endPart()` synchronously nulled `writeStream` but kept `state` at `STATE_BODY` until the async `ws.end` callback ran. If `req.on('end')` fired during that window, the end handler called `tick()`, which re-entered `STATE_BODY`, found another boundary in the trailing buffer, and called `writePartChunk()` → `writeStream.write(chunk)` on a `null` stream → process-killing `uncaughtException`. Fix: `tick()` now returns early when `pendingFlush > 0`; the existing `endPart` callback already re-invokes `tick()` once the flush completes and state has advanced. Added a regression test that defers the mocked `ws.end()` callback so the bug reproduces without the fix. File: `server/lib/multipart.js`.

- **Image Gen status badge stuck on "SD API unreachable" when Local mode is selected.** The page mounts before settings load, so the first `refreshStatus` call goes out with `mode=external` (the fallback in `effectiveMode = selectedMode || status?.mode || 'external'`). The local-mode probe that fires moments later (after settings populate `selectedMode='local'`) returns instantly because it just checks if the Python path is set — but the original external-mode probe is still in flight, blocking on a TCP timeout against the unconfigured SD API URL. When that slow response finally arrived, it overwrote the green "mflux/local" badge with the red "SD API unreachable — Settings" message, even though the Local chip was clearly selected. Token-guarded `refreshStatus` so each call records a monotonically incrementing token and only applies its `setStatus` if its token still matches the latest one — stale responses get silently dropped. File: `client/src/pages/ImageGen.jsx`.

- **Rapid Reader spacebar prompted for microphone access; global spacebar started the mic without showing the FAB.** The Rapid Reader's keydown listener and the Voice Widget's hotkey listener were both bound to `window` in the bubble phase, so pressing space to play/pause the reader also fired the voice agent's mic toggle (which then prompted for mic permission). And when the user pressed the global voice hotkey while the widget was hidden, capture started but the FAB and sidebar mic icon stayed in their "off" state because nothing flipped `voice.hidden` back to `0`. Fixes: (1) Rapid Reader now registers its keydown in the capture phase and calls `e.stopImmediatePropagation()` for handled keys, so the voice widget's bubble-phase listener never sees the event. (2) When the voice hotkey fires while the widget is hidden, the handler now calls `writeVoiceHidden(false)` which dispatches `VISIBILITY_EVENT` — the existing sync listener picks it up, the FAB renders, and the sidebar `VoiceToggleButton` flips to the engaged Mic icon. Files: `client/src/components/RapidReader.jsx`, `client/src/components/voice/VoiceWidget.jsx`.

## Changed

- **Pin all `package.json` dependencies and overrides to exact versions.** Removed `^` ranges from every dep/override across root, `server/`, and `client/` `package.json` files (e.g. `kokoro-js: ^1.2.1` → `1.2.1`; overrides like `path-to-regexp`, `lodash`, `basic-ftp`, `follow-redirects`, `brace-expansion`, `socket.io-parser`, and the nested `minimatch@3 → brace-expansion`). Lockfiles refreshed with no resolved-version drift — a defensive pin so future installs can't pick up a freshly-published patch in the override graph without an explicit bump.

## Added

- **Rapid Reader — Spritz-style RSVP speed reading.** New `/rapid-reader` page (Brain section) takes pasted prose and plays it back word-by-word at a configurable WPM (100–1000), with the focal letter ("Optimal Recognition Point") highlighted in red so the eye stays anchored to the center guide. Supports 1-word and 2-word chunks, color picker for the focal letter, paste-from-clipboard, and keyboard controls (space play/pause, ← → step, R restart, +/− WPM, Esc close). Per-token delay scales with sentence/clause boundaries and long-word penalty so reading stays comfortable at high speeds. Implemented as a reusable `<RapidReader>` component plus `<RapidReaderModal>` and `<RapidReaderTrigger>` wrappers — drop a `RapidReaderTrigger` next to any text-bearing surface to open the reader as a modal without leaving the page. First integration: a "Rapid Read" button in the Daily Briefing tab header that strips markdown markers and feeds the briefing prose into the reader. Voice nav resolves "rapid reader", "speed reader", and "rsvp" to the page. Files: `client/src/components/RapidReader.jsx`, `client/src/pages/RapidReader.jsx`, `client/src/App.jsx`, `client/src/components/Layout.jsx`, `client/src/components/cos/tabs/BriefingTab.jsx`, `server/lib/navManifest.js`.

- **Image Gen + Video Gen — hide/unhide media cards.** Each generated image/video now has an Eye/EyeOff toggle in its action row that moves the card into a per-page "hidden" section. Cards in the hidden section show as "Unhide" instead of "Hide". A collapsible "Show hidden (N) ▸" / "Hide hidden (N) ▾" toggle below the recent-renders grid reveals the hidden cards on demand. Server: `POST /api/image-gen/:filename/visibility` mutates the per-image sidecar JSON; `POST /api/video-gen/history/:id/visibility` mutates the matching record in `data/video-history.json`. Client: optimistic state update with rollback on server failure. The `hidden` flag flows through the existing normalizers so any future MediaCard consumer (e.g. Media History, gallery search) can opt into the same UI just by passing `onToggleHidden`. Files: `server/routes/imageGen.js`, `server/routes/videoGen.js`, `server/services/imageGen/local.js`, `server/services/videoGen/local.js`, `client/src/services/apiImageVideo.js`, `client/src/components/media/{MediaCard.jsx,normalize.js}`, `client/src/pages/{ImageGen.jsx,VideoGen.jsx}`.

- **Flux image-to-image (i2i) on the Image Gen page.** When Local mode is selected, a new "Init image" section under the model/seed grid lets the user upload a source image (PNG/JPG/WebP, ≤20MB) or pre-fill from a gallery basename via `?initImageFile=foo.png`. A strength slider (0.0 closer-to-source ... 1.0 freer-remix, default 0.4) controls how much of the source survives the diffusion. Server: `mflux-generate` is now invoked with `--image-path <path>` and `--image-strength <0..1>` when an init image is set; the path is realpath-validated against `PATHS.images` (defense-in-depth in both the route and the service). The route gained an `optionalUpload('initImage', ...)` middleware (also exported from `server/lib/multipart.js` for reuse) that no-ops on JSON bodies, so the existing JSON callers (SDAPI bridge, avatar route, the Imagine page's old payload) keep working unchanged. The dispatcher silently drops the i2i fields for External/Codex modes (which don't support i2i) so a misrouted payload still produces a useful txt2img result. The init image filename + strength are persisted to the metadata sidecar so a remix or audit can recover them. Client now exports a shared `buildFormData(fields)` helper used by both `generateVideo` and the new i2i upload branch in `ImageGen.jsx`. Files: `server/lib/multipart.js`, `server/routes/imageGen.js`, `server/services/imageGen/{index.js,local.js}`, `client/src/services/apiImageVideo.js`, `client/src/pages/ImageGen.jsx`.

- **`/brain/links` — per-repo malware/risk scan button.** New "Scan" action (ShieldCheck icon) sits next to Open / Pull on every cloned-status GitHub repo. Clicking it queues a CoS user task that runs `/do:scan` against the clone's `localPath` — the bundled slashdo command is a hardened read-only audit (no scanned code is executed, no fetched URLs are followed, hostile-until-proven-safe). The agent writes its full markdown report to `~/.claude/scans/<basename>-<date>.md` and the user is told to track progress in CoS Tasks. Backed by a new `POST /api/brain/links/:id/scan` endpoint that loads the bundled slashdo body via `loadSlashdoCommand('scan')` and bakes the repo path into the task context as `SCAN_DIR`. Pre-flight checks reject non-cloned links, missing `localPath`, vanished folders, and duplicate in-flight scans for the same repo. Files: `server/routes/brain.js`, `client/src/services/apiBrain.js`, `client/src/components/brain/tabs/LinksTab.jsx`. The slashdo submodule is also bumped from `a38ee04` to `50011ef` (picks up `do:pr-better` and the v2.12.0 release).

## Changed

- **`loadSlashdoFile()` now caches resolved command bodies** (server/lib/fileUtils.js) — slashdo command markdown + their `!cat ~/.claude/lib/...` includes are static within a server lifetime (submodule updates require restart anyway), so the per-call ~26KB read + N-include resolution is now memoized in a process-lifetime `Map` keyed by `${commandName}::${stripFrontmatter}`. Eliminates redundant disk I/O for callers like the new `/brain/links/:id/scan` endpoint and the existing `/api/cos/tasks/slashdo` dispatch.

- **`docs/DEPS.md` — dependency audit reference.** New living doc that records every third-party dependency in PortOS, its tier classification (1 acceptable / 2 audited / 3 removable), and per-package rationale for being kept. Generated by `/do:depfree` runs so future audits can cross-reference prior verdicts and re-audit triggers (e.g. "if `pm2`/`needle` drops `sax`, revisit"). First pass (2026-04-28, default mode): 30 deps audited, 0 removals — `kokoro-js` kept (only pure-JS in-process TTS, replacement requires Python subprocess + pooling), `sax` flagged REMOVE then downgraded to KEEP (transitive) because `pm2 → needle@2.4.0 → sax@1.4.4` already pulls the same major version (direct removal saves zero supply chain attack surface). PLAN.md now points at `docs/DEPS.md` instead of duplicating the table.

- **One-click "Enable HTTPS" button on the Instances tab.** The Tailnet DNS & trusted HTTPS explainer used to instruct users to drop into a shell and run `npm run setup:cert`. New button on the same banner runs the same flow at runtime via a new `POST /api/instances/provision-cert` endpoint, which calls `tailscale cert` for the local MagicDNS hostname, writes `data/certs/{cert,key}.pem`, and updates `meta.json`. Failure modes (Tailscale not installed, not running, no MagicDNS hostname, HTTPS Certificates feature disabled in the tailnet admin) surface as toasts with actionable guidance. Because the HTTPS listener type is decided at server boot in `lib/tailscale-https.js`, the response includes `requiresRestart: true` whenever PortOS is currently running over HTTP — the UI then prompts the user to restart so the new cert actually takes effect; if HTTPS was already running, the existing `certRenewer` hot-swap path picks up the renewed cert without a restart. The button is disabled until MagicDNS is detected, mirroring what the underlying CLI requires. Files: `server/services/certProvisioner.js` (new), `server/routes/instances.js`, `client/src/services/apiSystem.js`, `client/src/pages/Instances.jsx`.
- **Image Gen — third backend: Codex CLI `$imagegen`.** Image Gen can now route generation through the user's locally-installed Codex CLI built-in `image_gen` tool — invoked headlessly with `codex exec --skip-git-repo-check --sandbox workspace-write '$imagegen <prompt>'` so it uses the user's logged-in Codex session (no `OPENAI_API_KEY` required). New provider at `server/services/imageGen/codex.js` mirrors `local.js` (single-job model, SSE progress events) but harvests the resulting PNG from `~/.codex/generated_images/<session-id>/ig_*.png` after parsing the session id out of the codex launch banner — codex's stdout has no machine-readable image path, so banner-parse + dir-poll is the contract. Failure modes are surfaced clearly via `imageGenEvents`: empty session dir → "your Codex account may not allow image_gen", missing session id → "output format may have changed", non-zero exit → tail of stderr. Codex usage is gated by an explicit `Enable Codex Imagegen` setting because not every Codex plan exposes the `image_gen` tool — when disabled the option disappears from settings tile, image-gen page chip, voice tool dispatch, and palette gating up front.
- **Image Gen — per-render backend chip.** New chip strip on the Image Gen page lets the user flip between Local / External / Codex per-render without changing the saved default. Only viable backends (External URL set, Local Python set, Codex enabled) appear; the strip hides itself when only one backend is configured. Selection is sent as `mode:` in `POST /api/image-gen/generate` and the dispatcher honors the override. Codex mode also hides backend-irrelevant knobs (steps, seed, guidance/CFG, LoRAs, quantize) since the built-in `image_gen` tool decides those internally. `GET /api/image-gen/status?mode=codex` lets the page probe a non-default backend before saving.
- **CoS / voice — `image_generate` tool.** New voice tool that delegates to the Image Gen dispatcher. Defaults to the user's saved backend; agents pass `provider: 'local' | 'external' | 'codex' | 'auto'` to override. Codex is gated on `imageGen.codex.enabled` — disabled returns `{ ok: false, summary: 'Codex Imagegen is disabled — enable it in Settings → Image Gen first.' }` instead of the friendly server error. Tool joins the new `media` intent group (regex matches creation verbs paired with visual nouns plus `imagegen`) so small tool-use models only see it on imagery turns. Whitelisted in `PALETTE_ACTIONS` as `Generate Image` under section `Create` so `⌘K` exposes it for keyboard invocation.
- **Image Gen dispatcher — explicit per-request mode override.** `imageGen.generateImage()` now accepts `params.mode` (one of `external`, `local`, `codex`) to force a specific backend independent of `settings.imageGen.mode`. `imageGen.checkConnection({ mode })` similarly probes a specific backend. The dispatcher's SSE attach (`attachSseClient`) and `cancel()` now route across all async providers (local + codex), so `GET /api/image-gen/:jobId/events` and `POST /api/image-gen/cancel` work for codex jobs without needing a separate route. Files: `server/services/imageGen/codex.js` (new), `server/services/imageGen/index.js`, `server/routes/imageGen.js`, `server/services/voice/tools.js`, `server/routes/palette.js`, `client/src/components/settings/ImageGenTab.jsx`, `client/src/pages/ImageGen.jsx`. New unit tests for the codex provider (mocked spawn, session-id parse, file harvest, failure paths) and the `image_generate` voice tool (provider override, codex-disabled gate, dimension validation); server suite remains green.
