diff --git a/.changelog/v2.57.0.md b/.changelog/v2.57.0.md new file mode 100644 index 0000000000..2b3ca1dd84 --- /dev/null +++ b/.changelog/v2.57.0.md @@ -0,0 +1,37 @@ +# Release v2.57.0 + +Released: 2026-09-03 + +## Highlights + +**Rigging & 3D** +- Generated 3D characters can now be auto-skinned to a rig with a measured weight-coverage gate, then have animation clips retargeted onto them with a compatibility contract and a motion proof, so imported motion doesn't visibly break on unfamiliar skeletons. +- 3D models can be exported as USDZ, so they open directly in AR on an iPhone/iPad. +- Image-to-3D gained a subject-framing control so limbs and extremities survive conversion, and Blender rigging now resolves through a fail-closed runtime resolver with a readiness probe instead of failing silently mid-job. + +**Code review & CoS agents** +- Added OpenCode, Kimi, and MTPLX as selectable code reviewers, and reviewers now live under Models instead of a separate list. +- `pr-reviewer` stage 3 can run on any enabled CLI/TUI provider (spawned headless), with a documented Claude sandbox recipe. +- A fork PR's held CI run now releases only after the coordinator's own approval, never before — closing a gap where an untrusted fork's CI could run ahead of review. +- A failed installer can now queue a CoS agent to investigate the failure automatically, and the merge-gate contract is verified before a completing agent tears down its worktree. +- Numerous CoS/pr-reviewer robustness fixes: honoring a Stop signal mid Codex RPC, rescuing hook payloads from `output.txt` for direct CLI runs, keeping the sandbox-write fallback gated on a real `git apply` (not `--check`), retrying local code review without `reasoning_effort` when a model rejects thinking, and screening PRs for hidden/model-directed content instead of keyword co-occurrence. + +**Video generation** +- FastH3 can now render directly from FastVideo's own checkpoint by converting its DiT locally. +- Fixed FastMetal's picker showing the wrong download size, clamped FastH3 frame options to the pipeline's actual 5–15s window, and made `generate_fastvideo` importable on Python 3.9. + +**Onboarding & settings** +- A fresh install now gets a first-run card that stages one PortOS slice end to end. +- Added a Settings > Credentials page showing presence and source for configured credentials. +- Generated assets are now stamped with their source model/LoRA licenses. +- Scheduled-task cadences collapsed to a simpler On-Demand / Scheduled choice. +- Local coding runtime is now recommended based on detected hardware. + +**Reliability & UX fixes** +- Fixed a stuck-forever FLUX.2 venv health-check cache, boot schema DDL races across processes, worktree cleanup after a failed agent spawn, and object-URL leaks in ImageGen. +- Mobile/UI polish: 44px tap targets on MeatSpace log-row icons, wrapping long tokens in `
` blocks, clamping dropdown popovers to the viewport, and keeping long record names readable via a title tooltip.
+- App Management updates are now opt-in and route through the detached launcher with proper preflight guards, instead of running unannounced.
+
+## Full Changelog
+
+**Full Diff**: https://github.com/atomantic/PortOS/compare/v2.56.0...v2.57.0
diff --git a/.claude/skills/portos-socket-ui/SKILL.md b/.claude/skills/portos-socket-ui/SKILL.md
index 899f78d68c..0c85c8aefc 100644
--- a/.claude/skills/portos-socket-ui/SKILL.md
+++ b/.claude/skills/portos-socket-ui/SKILL.md
@@ -11,10 +11,10 @@ These are hard-won failure contracts from the Shell and CoS-agent views. Every o
 
 - **Single-subscriber socket resources need notify + recipient-relative advertise + filter + claim.** Some server-side resources (PTY shell sessions, etc.) intentionally store one attached socket and fan output to it. The contract: (1) emit `:detached` on the previous socket when a new socket takes over so the displaced client can drop its local view; (2) include an `attached: boolean` field on each list-entry payload, computed *relative to the recipient socket* (true only when bound to a different socket) — a globally-truthy `attached` makes a client's own sessions look unavailable to itself; (3) broadcast list updates from both attach AND detach paths; (4) auto-pick paths send `claim: true` and the server refuses to displace a different socket. Manual paths (tab click, deep-link URL) default to `claim: false` so explicit intent still wins. See `server/services/shell.js` for the canonical implementation.
 
-- **Pending socket-request tracking — `{ target, generation }` ref.** When a stateful socket operation is in flight, track it as `{ target, generation }` and increment `generation` on every change. Response handlers gate on strict equality with `target` — null/stale/cancelled all fall through, so a cancelled-mid-flight response can't re-activate after the user moved on. Deferred work (`setTimeout` fallbacks) captures `generation` and aborts if it advanced. Pair every cancellation path with explicit `cancelPendingAttach()`-style helpers rather than overloading a `clearActiveSession()` helper — clearing the displayed entity and cancelling an in-flight request are *separate* concerns, and conflating them cancels user-initiated switches when an unrelated session dies. See `client/src/pages/Shell.jsx` `pendingAttachRef` for the pattern.
+- **Pending socket-request tracking — `{ target, generation }` ref.** When a stateful socket operation is in flight, track it as `{ target, generation }` and increment `generation` on every change. Response handlers gate on strict equality with `target` — null/stale/cancelled all fall through, so a cancelled-mid-flight response can't re-activate after the user moved on. Deferred work (`setTimeout` fallbacks) captures `generation` and aborts if it advanced. Pair every cancellation path with explicit `cancelPendingAttach()`-style helpers rather than overloading a `clearActiveSession()` helper — clearing the displayed entity and cancelling an in-flight request are *separate* concerns, and conflating them cancels user-initiated switches when an unrelated session dies. See `client/src/hooks/useShellSession.js` `pendingAttachRef` for the pattern.
 
 - **Server-correlate every async response, then filter display.** When the server emits `:error` in response to a client request, include the original `sessionId` / request id in the payload so the client can match against its pending state. Drop stale errors silently and gate the red-error display on correlation — rendering before classification flashes noise in the UI for requests the user has already moved past (rapid tab clicks, expected `claim:true` race rejections). Passive errors against the currently-displayed resource (e.g. `shell:input` to a now-dead session) should still display, but must not mutate pending state.
 
 - **Distinguish intentional idle from passive idle.** A "no entity displayed" state can come from a user action (Stop / dismiss) or from passive circumstance (initial load found everything in-use elsewhere). Recovery branches that auto-adopt the next free entity must gate on a `userIdle*Ref` flag set by explicit user-clear paths and cleared by every user-initiated start/attach. The gate needs to cover every reconnect-triggered re-init path, not just the initial-load branch — a transient disconnect resets initialization flags, and an empty-list auto-start or survivor adoption can otherwise undo an explicit Stop on reconnect.
 
-- **Deferred work must respect both staleness and unmount.** Any `setTimeout`-scheduled side effect that emits to the network or mutates shared state needs two guards: (1) a generation counter check so user actions during the delay window abort it, and (2) a `mountedRef` so a navigation-away unmount stops it from firing into the void. Pattern: `const mountedRef = useRef(true); useEffect(() => () => { mountedRef.current = false; }, []);` — never reset to `true` (handles dev-mode double-mount cleanly). Without the unmount guard, a deferred socket emit can claim a resource (e.g. shell session) with no listener left to render it.
+- **Deferred work must respect both staleness and unmount.** Any `setTimeout`-scheduled side effect that emits to the network or mutates shared state needs two guards: (1) a generation counter check so user actions during the delay window abort it, and (2) a `mountedRef` so a navigation-away unmount stops it from firing into the void. Use `useMounted()` from `client/src/hooks/useMounted.js`; it re-arms on every mount, which StrictMode's mount-cleanup-remount cycle requires. Do not hand-roll the guard even correctly — `client/src/hooks/mountedRefConventions.test.js` rejects both a `useRef(true)` that is only ever set to `false` and any re-implementation of the hook. Without the unmount guard, a deferred socket emit can claim a resource (e.g. shell session) with no listener left to render it.
diff --git a/.env.example b/.env.example
index c60696a419..ae0783973d 100644
--- a/.env.example
+++ b/.env.example
@@ -25,16 +25,19 @@ PGPASSWORD=portos
 # Loopback HTTP port spawned when HTTPS is active so local curl skips the cert (default: 5553)
 # PORTOS_HTTP_PORT=5553
 
+# Port the Express server listens on (server/index.js; default: 5555 from lib/ports.js)
+# PORT=5555
+
+# VNC port the Remote Desktop broker connects to on this machine
+# (remoteDesktop.js; default: 5900). A non-numeric or out-of-range value falls
+# back to the default rather than failing.
+# PORTOS_VNC_PORT=5900
+
 # pm2 restarts portos-server when its RSS crosses this (memory-leak safety valve).
 # Default 4G fits small installs; raise it on a big-RAM workstation so a long
 # session / heavy SSE load doesn't cause spurious restarts (e.g. 32G on 128 GB).
 # PORTOS_SERVER_MAX_MEMORY=32G
 
-# Same ceiling for the Vite dev server (portos-ui). Default 1G — a dev server that
-# transforms modules on demand should sit well under that; raise it only if a huge
-# client tree makes Vite restart during normal editing.
-# PORTOS_UI_MAX_MEMORY=2G
-
 # Override the base URLs advertised to clients (auto-derived from PORTOS_HOST/PORT if unset)
 # PORTOS_API_URL=https://my-machine.ts.net:5555
 # PORTOS_UI_URL=https://my-machine.ts.net:5555
@@ -69,6 +72,12 @@ PGPASSWORD=portos
 # Chief of Staff runner endpoint (used when CoS runs as a separate process)
 # COS_RUNNER_URL=http://localhost:5558
 
+# Eidoverse world server endpoints (eidoverseWorld.js). The WebSocket URL defaults
+# to ws://127.0.0.1:/ws; the HTTP URL is derived from it unless set.
+# Point these at another host only when the world runs off-box.
+# EIDOVERSE_WS_URL=ws://127.0.0.1:8940/ws
+# EIDOVERSE_HTTP_URL=http://127.0.0.1:8940/
+
 # Active local LLM backend: "ollama" or "lmstudio" (chosen at setup time; managed in Settings → Local LLMs)
 # LLM_BACKEND=ollama
 
@@ -96,6 +105,11 @@ PGPASSWORD=portos
 # Maximum Grok image-generation runtime in milliseconds (default: 1200000 / 20 minutes)
 # GROK_TIMEOUT_MS=1200000
 
+# Maximum Grok VIDEO runtime in milliseconds (videoGen/grok.js; default: 1800000 /
+# 30 minutes). Video takes longer than an image, so this cap is separate from
+# GROK_TIMEOUT_MS — keep it above the 20-minute cloud-lane idle watchdog.
+# GROK_VIDEO_TIMEOUT_MS=1800000
+
 # Maximum Antigravity image-generation runtime in milliseconds (default: 1200000 / 20 minutes)
 # AGY_IMAGEGEN_TIMEOUT_MS=1200000
 
@@ -141,6 +155,11 @@ PGPASSWORD=portos
 # because a bare `bash` often resolves to WSL, which can't see drive paths.
 # PORTOS_BASH=/bin/bash
 
+# Python interpreter scripts/setup-image-video.sh uses as the venv base
+# (setupScriptRunner.js). An explicit value always wins; on Windows PortOS
+# otherwise auto-detects one, avoiding a conda base whose venv can't load torch.
+# PYTHON_BIN=/usr/bin/python3
+
 # Shell binary for interactive PTY sessions (the Shell page, agent TUI shells).
 # Auto-detected if unset: on Windows PowerShell 7, else Windows PowerShell, else
 # cmd.exe (last, because it can't reach another drive by anything you'd type —
@@ -171,9 +190,29 @@ PGPASSWORD=portos
 # Ollama context window size passed to new model loads (default: 32768)
 # OLLAMA_NUM_CTX=32768
 
+# Slotstream model cache directory override (slotstreamModels.js; default:
+# ~/.slotstream/models). The binary always lives in ~/.slotstream/bin.
+# SLOTSTREAM_MODEL_DIR=/path/to/slotstream/models
+
+# Abort a speculative-decoding model download after this many milliseconds with
+# no bytes received (specDecodeModels.js; default: 1200000 / 20 minutes). Raise
+# it for a slow Hugging Face/CDN handshake; a download still receiving bytes is
+# never cut off by this.
+# SPEC_DECODE_IDLE_STALL_MS=1200000
+
 # Video process grace period after output completion in milliseconds (default: 40000)
 # VIDEOGEN_COMPLETION_WATCHDOG_MS=40000
 
+# FFLF/ltx2 stage-2 pixel-frame budget (videoGen/renderArgs.js). Unset, PortOS
+# scales it to detected unified memory. Raise it on a big-memory box; lower it if
+# a render OOMs. This is the single cap both the worker and the client honour.
+# FFLF_LTX2_PIXEL_BUDGET=8000000
+
+# Megapixel ceiling for a local image regeneration render (imageGen/regen.js;
+# default: 2.0). The result is upscaled back to the source's exact dimensions, so
+# this bounds compute, not output size. Raise it on a high-memory machine.
+# PORTOS_REGEN_MAX_MP=2.0
+
 # Terminate an idle queued video job after this many milliseconds (default: 1800000 / 30 minutes)
 # MEDIA_JOB_WATCHDOG_VIDEO_MS=1800000
 
@@ -207,15 +246,42 @@ PGPASSWORD=portos
 # Automatic LoRA checkpoint resumes allowed after soft stalls (default: 2; 0 disables)
 # LORA_TRAIN_STALL_MAX_AUTO_RESUMES=2
 
+# Cap LoRA training quantization at this bit width (loraTraining/runtimes.js).
+# Only 8 or 4 are honoured; anything else is ignored and the memory-derived tier
+# stands. Set it to force a smaller quant on a machine whose auto-tier OOMs.
+# LORA_TRAIN_MAX_QUANT_BITS=4
+
 # Ingredient catalog revisions retained per ingredient (default: 50)
 # CATALOG_REVISION_RETENTION=50
 
 # LM Studio model id auto-installed for voice tool calling (default: built-in fallback chain)
 # PORTOS_VOICE_DEFAULT_TOOL_MODEL=lmstudio-community/Qwen2.5-3B-Instruct-GGUF
 
+# Signal Desktop install directory read by Signal sync (signalSync.js; default:
+# ~/Library/Application Support/Signal). SIGNAL_CONFIG_PATH and SIGNAL_DB_PATH are
+# derived from it, so relocating a Signal install usually only needs this one.
+# SIGNAL_DIR=/path/to/signal
+
 # Signal sync config file path (default: SIGNAL_DIR/config.json)
 # SIGNAL_CONFIG_PATH=/path/to/signal/config.json
 
+# Signal sync SQLCipher database path (default: SIGNAL_DIR/sql/db.sqlite)
+# SIGNAL_DB_PATH=/path/to/signal/sql/db.sqlite
+
+# Test/CI hook that supplies the "Signal Safe Storage" password instead of
+# shelling out to the macOS keychain (signalSync.js). Leave unset on a real
+# install — PortOS reads the keychain itself, and this would put a live
+# credential in a plaintext file.
+# SIGNAL_KEYCHAIN_PASSWORD=fake-keychain-password
+
+# macOS Contacts (AddressBook) directory read by contact sync (contactsSync.js;
+# default: ~/Library/Application Support/AddressBook)
+# CONTACTS_AB_ROOT=/path/to/AddressBook
+
+# macOS Messages database read by iMessage sync (imessageSync.js; default:
+# ~/Library/Messages/chat.db)
+# IMESSAGE_CHAT_DB=/path/to/chat.db
+
 # Privacy Center PII Vault encryption key — 32 bytes, hex (64 chars) or base64.
 # Auto-generated and appended to .env on the first vault write if unset. Back
 # it up: losing it makes every encrypted vault value unrecoverable.
diff --git a/.gitattributes b/.gitattributes
index d541f49c98..f8263da618 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -27,3 +27,18 @@
 *.woff2 binary
 *.ttf binary
 *.eot binary
+
+# Sorted catalogs and barrels: one line per module, so concurrent branches
+# conflict on the same insertion hunk. `union` keeps both sides;
+# scripts/catalog-merge-union.test.js catches a doubled or resurrected line
+# and pins this list to the barrel list in scripts/ci-test-plan.js. Rationale
+# and rules: AGENTS.md "Module Organization".
+server/lib/README.md merge=union
+server/lib/index.js merge=union
+client/src/lib/README.md merge=union
+client/src/lib/index.js merge=union
+client/src/hooks/README.md merge=union
+client/src/hooks/index.js merge=union
+client/src/utils/README.md merge=union
+client/src/utils/index.js merge=union
+client/src/services/README.md merge=union
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d1b3911bf5..bab7471dfb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -51,6 +51,13 @@ jobs:
       windows_mode: ${{ steps.plan.outputs.windows_mode }}
       windows_files: ${{ steps.plan.outputs.windows_files }}
       windows_sources: ${{ steps.plan.outputs.windows_sources }}
+      # JSON arrays of shard indexes — `[1]` for a scoped plan, `[1..n]` for a
+      # full one. Each leaf job builds its matrix from these, because a
+      # job-level `if` cannot read `matrix` and so cannot skip extra shards on
+      # its own. See FULL_SUITE_SHARDS in scripts/ci-test-plan.js.
+      server_shards: ${{ steps.plan.outputs.server_shards }}
+      client_shards: ${{ steps.plan.outputs.client_shards }}
+      windows_shards: ${{ steps.plan.outputs.windows_shards }}
       suite_reasons: ${{ steps.plan.outputs.suite_reasons }}
     steps:
       - uses: actions/checkout@v7
@@ -91,27 +98,36 @@ jobs:
           SMOKE_MODE: ${{ steps.plan.outputs.smoke }}
           WINDOWS_MODE: ${{ steps.plan.outputs.windows }}
           WINDOWS_TEST_MODE: ${{ steps.plan.outputs.windows_mode }}
+          SERVER_SHARDS: ${{ steps.plan.outputs.server_shards }}
+          CLIENT_SHARDS: ${{ steps.plan.outputs.client_shards }}
+          WINDOWS_SHARDS: ${{ steps.plan.outputs.windows_shards }}
           SUITE_REASONS: ${{ steps.plan.outputs.suite_reasons }}
         run: |
           {
             echo "### CI impact plan"
             echo
             echo "- Reason: \`${PLAN_REASON}\`"
-            echo "- Server tests: \`${SERVER_MODE}\`"
-            echo "- Client tests: \`${CLIENT_MODE}\`"
+            echo "- Server tests: \`${SERVER_MODE}\` (shards \`${SERVER_SHARDS}\`)"
+            echo "- Client tests: \`${CLIENT_MODE}\` (shards \`${CLIENT_SHARDS}\`)"
             echo "- DB tests: \`${DB_MODE}\`"
             echo "- Client lint: \`${LINT_MODE}\`"
             echo "- Client build: \`${BUILD_MODE}\`"
             echo "- Server smoke: \`${SMOKE_MODE}\`"
-            echo "- Windows server tests: \`${WINDOWS_MODE}\` (\`${WINDOWS_TEST_MODE}\`)"
+            echo "- Windows server tests: \`${WINDOWS_MODE}\` (\`${WINDOWS_TEST_MODE}\`, shards \`${WINDOWS_SHARDS}\`)"
             echo "- Suite selection reasons: \`${SUITE_REASONS}\`"
           } >> "$GITHUB_STEP_SUMMARY"
 
   server:
-    name: Server tests
+    # Sharded on a full plan — see docs/GITHUB_ACTIONS.md "Full-suite sharding".
+    # The matrix comes from the planner; once-only steps pin to shard 1, and the
+    # transform-artifact cache key carries the shard so parallel saves don't race.
+    name: Server tests (${{ matrix.shard }}/${{ strategy.job-total }})
     needs: impact
     if: needs.impact.outputs.server_mode != 'skip'
     runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        shard: ${{ fromJSON(needs.impact.outputs.server_shards) }}
     permissions:
       contents: read
       actions: write
@@ -226,11 +242,35 @@ jobs:
           path: |
             server/node_modules/.vite
             server/node_modules/.vitest
-          key: vitest-server-${{ runner.os }}-${{ hashFiles('server/package-lock.json', 'server/vitest.config.js', 'scripts/vitestCiPool.js') }}
+          key: vitest-server-${{ runner.os }}-${{ hashFiles('server/package-lock.json', 'server/vitest.config.js', 'scripts/vitestCiPool.js') }}-${{ matrix.shard }}of${{ strategy.job-total }}
           restore-keys: |
             vitest-server-${{ runner.os }}-${{ hashFiles('server/package-lock.json') }}-
             vitest-server-${{ runner.os }}-
 
+      # `autofixer/` is a real install target — its own package.json, its own
+      # tracked lockfile, its own .npmrc — installed by `npm run setup` and by
+      # scripts/ensure-deps.js on every `npm start`. Nothing else in CI resolves
+      # it, so a lockfile that no longer installs (a yanked version, a corrupted
+      # integrity hash, a manifest/lockfile mismatch) would ship green and only
+      # break on a user's machine at setup time. The parity assertions in
+      # server/dependency-overrides.test.js are static — they parse the JSON;
+      # only `npm ci` proves the tree still resolves.
+      #
+      # Deliberately uncached and deliberately ungated within the job: a cache
+      # hit that skipped the install would skip the very check this step exists
+      # for, and the job as a whole already gates on the planner's server_mode
+      # (autofixer/ is one of the server runner's roots in ci-test-plan.js, so a
+      # change here selects this job). Unlike the once-only steps that pin to
+      # shard 1, it runs on every shard: autofixer/*.test.js is globbed by the
+      # server runner, and which shard picks up a file that imports `express` is
+      # not knowable from here. 67 packages, well under a second.
+      #
+      # No rebuild step follows. autofixer/.npmrc pins ignore-scripts=true and
+      # scripts/trusted-rebuilds.js deliberately lists no rebuilds for this
+      # workspace, because nothing it depends on ships a native addon.
+      - name: Install autofixer dependencies
+        run: npm ci --prefix autofixer
+
       - name: Check server entry-point syntax
         run: node --check server/index.js
 
@@ -240,6 +280,7 @@ jobs:
           CI_TEST_MODE: ${{ needs.impact.outputs.server_mode }}
           CI_TEST_FILES: ${{ needs.impact.outputs.server_files }}
           CI_TEST_SOURCES: ${{ needs.impact.outputs.server_sources }}
+          CI_SHARD: ${{ matrix.shard }}/${{ strategy.job-total }}
           # Keep successful-test output signal-dense. Developers can reproduce
           # locally without this flag when assertion context needs app logs.
           PORTOS_TEST_QUIET: 1
@@ -252,7 +293,7 @@ jobs:
       # backend. It does need the native rebuild (server boot loads node-pty),
       # which is why it lives on this job rather than a third installer.
       - name: Smoke-boot server
-        if: needs.impact.outputs.smoke == 'true'
+        if: needs.impact.outputs.smoke == 'true' && matrix.shard == 1
         run: npm run smoke
 
       - name: Cancel sibling CI jobs after failure
@@ -262,10 +303,14 @@ jobs:
         run: node scripts/cancel-current-ci-run.js
 
   client:
-    name: Client tests and build
+    # Sharded like the server job; lint, build, and the bundle budget run on shard 1.
+    name: Client tests and build (${{ matrix.shard }}/${{ strategy.job-total }})
     needs: impact
     if: needs.impact.outputs.client_mode != 'skip' || needs.impact.outputs.build == 'true' || needs.impact.outputs.lint_mode != 'skip'
     runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        shard: ${{ fromJSON(needs.impact.outputs.client_shards) }}
     permissions:
       contents: read
       actions: write
@@ -296,13 +341,13 @@ jobs:
           path: |
             client/node_modules/.vite
             client/node_modules/.vitest
-          key: vitest-client-${{ runner.os }}-${{ hashFiles('client/package-lock.json', 'client/vitest.config.js', 'scripts/vitestCiPool.js') }}
+          key: vitest-client-${{ runner.os }}-${{ hashFiles('client/package-lock.json', 'client/vitest.config.js', 'scripts/vitestCiPool.js') }}-${{ matrix.shard }}of${{ strategy.job-total }}
           restore-keys: |
             vitest-client-${{ runner.os }}-${{ hashFiles('client/package-lock.json') }}-
             vitest-client-${{ runner.os }}-
 
       - name: Lint client
-        if: needs.impact.outputs.lint_mode != 'skip'
+        if: needs.impact.outputs.lint_mode != 'skip' && matrix.shard == 1
         env:
           CI_LINT_MODE: ${{ needs.impact.outputs.lint_mode }}
           CI_LINT_FILES: ${{ needs.impact.outputs.lint_files }}
@@ -314,17 +359,18 @@ jobs:
           CI_TEST_MODE: ${{ needs.impact.outputs.client_mode }}
           CI_TEST_FILES: ${{ needs.impact.outputs.client_files }}
           CI_TEST_SOURCES: ${{ needs.impact.outputs.client_sources }}
+          CI_SHARD: ${{ matrix.shard }}/${{ strategy.job-total }}
         run: node scripts/run-ci-tests.js client
 
       - name: Build client
-        if: needs.impact.outputs.build == 'true'
+        if: needs.impact.outputs.build == 'true' && matrix.shard == 1
         run: npm run build --prefix client
 
       # The Scalar bundle budget can only be measured against a real build, and it
       # skips itself when client/dist is absent — so it runs here, not in the unit
       # test job. See client/src/pages/ApiExplorer.bundle.test.js.
       - name: Check API Explorer bundle budget
-        if: needs.impact.outputs.build == 'true'
+        if: needs.impact.outputs.build == 'true' && matrix.shard == 1
         run: npm run test --prefix client -- ApiExplorer.bundle
 
       - name: Cancel sibling CI jobs after failure
@@ -478,10 +524,14 @@ jobs:
         run: node scripts/cancel-current-ci-run.js
 
   windows-server:
-    name: Windows server unit tests
+    # Sharded like the server job.
+    name: Windows server unit tests (${{ matrix.shard }}/${{ strategy.job-total }})
     needs: impact
     if: needs.impact.outputs.windows == 'true'
     runs-on: windows-latest
+    strategy:
+      matrix:
+        shard: ${{ fromJSON(needs.impact.outputs.windows_shards) }}
     permissions:
       contents: read
       actions: write
@@ -581,7 +631,7 @@ jobs:
           path: |
             server/node_modules/.vite
             server/node_modules/.vitest
-          key: vitest-server-${{ runner.os }}-${{ hashFiles('server/package-lock.json', 'server/vitest.config.js', 'scripts/vitestCiPool.js') }}
+          key: vitest-server-${{ runner.os }}-${{ hashFiles('server/package-lock.json', 'server/vitest.config.js', 'scripts/vitestCiPool.js') }}-${{ matrix.shard }}of${{ strategy.job-total }}
           restore-keys: |
             vitest-server-${{ runner.os }}-${{ hashFiles('server/package-lock.json') }}-
             vitest-server-${{ runner.os }}-
@@ -594,6 +644,7 @@ jobs:
           CI_TEST_MODE: ${{ needs.impact.outputs.windows_mode }}
           CI_TEST_FILES: ${{ needs.impact.outputs.windows_files }}
           CI_TEST_SOURCES: ${{ needs.impact.outputs.windows_sources }}
+          CI_SHARD: ${{ matrix.shard }}/${{ strategy.job-total }}
           PORTOS_TEST_QUIET: 1
           PGPASSWORD: portos
         run: node scripts/run-ci-tests.js server
diff --git a/AGENTS.md b/AGENTS.md
index c35c05f4f6..7ceb6a2541 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -172,6 +172,8 @@ Any new file in `server/lib/`, `client/src/lib/`, `client/src/hooks/`, `client/s
 
 This is the one rule that keeps catalogs from rotting, and it is enforced: `server/lib/index.test.js` and its client counterparts fail when a non-test `.js` file is missing from either the barrel or the README.
 
+**These catalogs and barrels merge with git's `union` driver** (`.gitattributes`), because every branch inserts one sorted line into each and two concurrent branches routinely collide on the same hunk. Union keeps both sides, which is right for an insertion and wrong for an edit or deletion beside one — so after a rebase that touched a catalog, `scripts/catalog-merge-union.test.js` (always-run) fails on a doubled or resurrected row; keep one and move on. Never give a file with real code paths the `union` attribute; the guard rejects a `.js` that is not a pure re-export barrel.
+
 **Name collisions.** When two modules in one directory export the same identifier (e.g. `settingsUpdateInputSchema` in both `brainValidation.js` and `digitalTwinValidation.js`), the barrel uses `export * as ` namespace exports so callers reach for `brainValidation.settingsUpdateInputSchema` explicitly. Catch-all modules like `validation.js` stay flat. The collision-detector test fails if two flat-`export *` modules ever share an identifier, forcing namespace resolution where the conflict is introduced.
 
 Existing deep imports (`import { x } from '../lib/foo.js'`) keep working — the barrel exists for *discovery*, not to force a re-import. New code may use either form. The worked example for "barrel + documented exports" is `server/lib/aiToolkit/index.js`.
diff --git a/autofixer/package-lock.json b/autofixer/package-lock.json
index 23937aa579..476645caf4 100644
--- a/autofixer/package-lock.json
+++ b/autofixer/package-lock.json
@@ -600,9 +600,9 @@
       }
     },
     "node_modules/qs": {
-      "version": "6.15.3",
-      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
-      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "version": "6.16.0",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
+      "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
       "license": "BSD-3-Clause",
       "dependencies": {
         "es-define-property": "^1.0.1",
diff --git a/autofixer/package.json b/autofixer/package.json
index 4cee97ee95..bed0a812f0 100644
--- a/autofixer/package.json
+++ b/autofixer/package.json
@@ -13,6 +13,6 @@
   "overrides": {
     "path-to-regexp": "8.4.2",
     "body-parser": "2.3.0",
-    "qs": "6.15.3"
+    "qs": "6.16.0"
   }
 }
diff --git a/autofixer/server.js b/autofixer/server.js
index c1091fff79..ecb25e4c5e 100644
--- a/autofixer/server.js
+++ b/autofixer/server.js
@@ -1,8 +1,5 @@
-import { spawn } from 'child_process';
 import { readFile, writeFile, mkdir, access } from 'fs/promises';
-import { join, dirname } from 'path';
-import { fileURLToPath } from 'url';
-import { createRequire } from 'module';
+import { join } from 'path';
 // Dependency-light shared module (node builtins + pure arg builder only), so
 // importing it from this standalone process doesn't pull in the AI toolkit.
 // Lets the autofixer honor the user's configured CLI provider/model instead
@@ -23,9 +20,7 @@ import {
   revertDiffFromLive,
   runVerifyCommand,
 } from './sandbox.js';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
+import { execPm2, DATA_DIR, AUTOFIXER_DIR, INDEX_FILE, loadApps } from './shared.js';
 
 // Prepend the guarded pm2 shim to this process's PATH as defense-in-depth. The
 // fix agent runs in an isolated worktree with a sanitized env and (for claude)
@@ -35,34 +30,10 @@ const __dirname = dirname(__filename);
 // preserves this guarded PATH into the agent's env.
 Object.assign(process.env, agentGuardEnv());
 
-// Resolve PM2 binary to avoid pm2.cmd on Windows (creates visible CMD windows)
-const require = createRequire(import.meta.url);
-const PM2_BIN = join(dirname(require.resolve('pm2/package.json')), 'bin', 'pm2');
-
-/** Execute a PM2 CLI command via node (bypasses pm2.cmd) */
-function execPm2(pm2Args) {
-  return new Promise((resolve, reject) => {
-    const child = spawn(process.execPath, [PM2_BIN, ...pm2Args], { windowsHide: true });
-    let stdout = '';
-    let stderr = '';
-    child.stdout.on('data', (d) => { stdout += d.toString(); });
-    child.stderr.on('data', (d) => { stderr += d.toString(); });
-    child.on('close', (code) => {
-      if (code !== 0) return reject(new Error(stderr || `pm2 exited with code ${code}`));
-      resolve({ stdout, stderr });
-    });
-    child.on('error', reject);
-  });
-}
-
-// Paths
-const DATA_DIR = join(__dirname, '../data');
-const APPS_FILE = join(DATA_DIR, 'apps.json');
+// Paths not shared with ui.js
 const PROVIDERS_FILE = join(DATA_DIR, 'providers.json');
 const SETTINGS_FILE = join(DATA_DIR, 'settings.json');
-const AUTOFIXER_DIR = join(DATA_DIR, 'autofixer');
 const SESSIONS_DIR = join(AUTOFIXER_DIR, 'sessions');
-const INDEX_FILE = join(AUTOFIXER_DIR, 'index.json');
 // Disposable worktrees for isolated repair runs (gitignored under data/).
 const WORKTREES_DIR = join(AUTOFIXER_DIR, 'worktrees');
 // Bound the agent-proposed patch before it can reach the live checkout.
@@ -75,13 +46,6 @@ const CHECK_INTERVAL = 15 * 60 * 1000; // 15 minutes
 let checkTimer = null;
 let shuttingDown = false;
 
-// Load apps from PortOS
-async function loadApps() {
-  const data = await readFile(APPS_FILE, 'utf8').catch(() => '{"apps":{}}');
-  const parsed = JSON.parse(data);
-  return Object.entries(parsed.apps || {}).map(([id, app]) => ({ id, ...app }));
-}
-
 // Parse JSON, returning `fallback` on read OR parse failure. A corrupt config
 // file (partial write, hand-edit) must not throw inside fixProcess — this runs
 // in the autofixer's interval loop, outside any request lifecycle, where an
diff --git a/autofixer/shared.js b/autofixer/shared.js
new file mode 100644
index 0000000000..7cac628b46
--- /dev/null
+++ b/autofixer/shared.js
@@ -0,0 +1,47 @@
+// Plumbing shared by the autofixer's two PM2-managed processes — `server.js`
+// (the repair loop) and `ui.js` (the dashboard). Kept package-local and
+// dependency-light (node builtins only): PortOS's own `server/services/pm2.js`
+// has an equivalent `execPm2`, but importing it here would drag the whole
+// server dependency graph into a package whose package.json declares only
+// express.
+import { spawn } from 'child_process';
+import { readFile } from 'fs/promises';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { createRequire } from 'module';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+// Resolve PM2 binary to avoid pm2.cmd on Windows (creates visible CMD windows)
+const require = createRequire(import.meta.url);
+export const PM2_BIN = join(dirname(require.resolve('pm2/package.json')), 'bin', 'pm2');
+
+/** Execute a PM2 CLI command via node (bypasses pm2.cmd) */
+export function execPm2(pm2Args) {
+  return new Promise((resolve, reject) => {
+    const child = spawn(process.execPath, [PM2_BIN, ...pm2Args], { windowsHide: true });
+    let stdout = '';
+    let stderr = '';
+    child.stdout.on('data', (d) => { stdout += d.toString(); });
+    child.stderr.on('data', (d) => { stderr += d.toString(); });
+    child.on('close', (code) => {
+      if (code !== 0) return reject(new Error(stderr || `pm2 exited with code ${code}`));
+      resolve({ stdout, stderr });
+    });
+    child.on('error', reject);
+  });
+}
+
+// Paths. Resolved from THIS module's location (both consumers are siblings in
+// `autofixer/`), so every process agrees on one `data/` directory.
+export const DATA_DIR = join(__dirname, '../data');
+export const APPS_FILE = join(DATA_DIR, 'apps.json');
+export const AUTOFIXER_DIR = join(DATA_DIR, 'autofixer');
+export const INDEX_FILE = join(AUTOFIXER_DIR, 'index.json');
+
+// Load apps from PortOS
+export async function loadApps() {
+  const data = await readFile(APPS_FILE, 'utf8').catch(() => '{"apps":{}}');
+  const parsed = JSON.parse(data);
+  return Object.entries(parsed.apps || {}).map(([id, app]) => ({ id, ...app }));
+}
diff --git a/autofixer/shared.test.js b/autofixer/shared.test.js
new file mode 100644
index 0000000000..a5191af274
--- /dev/null
+++ b/autofixer/shared.test.js
@@ -0,0 +1,82 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { existsSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { createRequire } from 'module';
+
+// loadApps() reads a fixed on-disk path, so the read is faked to keep the
+// fallback assertions independent of whether this install has a data/apps.json.
+const readFileMock = vi.hoisted(() => vi.fn());
+vi.mock('fs/promises', async (importOriginal) => ({
+  ...(await importOriginal()),
+  readFile: (...args) => readFileMock(...args),
+}));
+
+// shared.js resolves the PM2 binary at import time, and Node walks node_modules
+// upward from `autofixer/` — so it needs the ROOT install. CI installs only
+// `server/node_modules` (`npm ci --prefix server`), which is never on that path,
+// so skip there rather than fail: the autofixer only ever runs from a full
+// `npm run install:all` checkout, which is where this suite has to hold.
+const require = createRequire(import.meta.url);
+const pm2Installed = (() => {
+  try {
+    require.resolve('pm2/package.json');
+    return true;
+  } catch {
+    return false;
+  }
+})();
+const describeShared = pm2Installed ? describe : describe.skip;
+const shared = pm2Installed ? await import('./shared.js') : {};
+
+const AUTOFIXER_SRC_DIR = dirname(fileURLToPath(import.meta.url));
+
+describeShared('autofixer/shared — PM2 binary resolution', () => {
+  // server.js and ui.js both spawn `node ` rather than `pm2`, so a PM2
+  // package layout change would otherwise surface only at runtime, on the next
+  // repair attempt or dashboard restart.
+  it('resolves the JS entry point (not pm2.cmd) and it exists on disk', () => {
+    expect(shared.PM2_BIN.endsWith(join('bin', 'pm2'))).toBe(true);
+    expect(existsSync(shared.PM2_BIN)).toBe(true);
+  });
+});
+
+describeShared('autofixer/shared — data paths', () => {
+  // Both PM2 processes must agree on one data/ directory; resolving from this
+  // module's own location is what guarantees that. Spelled as a dirname climb
+  // rather than a '..' path literal so the repo-wide test-data isolation guard
+  // (server/lib/testDataIsolation.guards.test.js) doesn't read this string-only
+  // comparison as a suite that addresses the live data/ tree — nothing here
+  // touches the filesystem.
+  it('anchors every path to the package-sibling data/ directory', () => {
+    expect(shared.DATA_DIR).toBe(join(dirname(AUTOFIXER_SRC_DIR), 'data'));
+    expect(shared.APPS_FILE).toBe(join(shared.DATA_DIR, 'apps.json'));
+    expect(shared.AUTOFIXER_DIR).toBe(join(shared.DATA_DIR, 'autofixer'));
+    expect(shared.INDEX_FILE).toBe(join(shared.AUTOFIXER_DIR, 'index.json'));
+  });
+});
+
+describeShared('autofixer/shared — loadApps', () => {
+  beforeEach(() => {
+    readFileMock.mockReset();
+  });
+
+  it('returns [] when the apps file is missing', async () => {
+    readFileMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
+    await expect(shared.loadApps()).resolves.toEqual([]);
+  });
+
+  it('returns [] when the apps file has no apps key', async () => {
+    readFileMock.mockResolvedValue('{}');
+    await expect(shared.loadApps()).resolves.toEqual([]);
+  });
+
+  it('flattens the apps map into records carrying their id', async () => {
+    readFileMock.mockResolvedValue(JSON.stringify({
+      apps: { 'example-app': { pm2ProcessNames: ['example-api'], repoPath: '/srv/example' } },
+    }));
+    await expect(shared.loadApps()).resolves.toEqual([
+      { id: 'example-app', pm2ProcessNames: ['example-api'], repoPath: '/srv/example' },
+    ]);
+  });
+});
diff --git a/autofixer/ui.js b/autofixer/ui.js
index ed2b96215b..2ec8bd8ac3 100644
--- a/autofixer/ui.js
+++ b/autofixer/ui.js
@@ -3,34 +3,14 @@ import { spawn } from 'child_process';
 import { readFile } from 'fs/promises';
 import { join, dirname } from 'path';
 import { fileURLToPath } from 'url';
-import { createRequire } from 'module';
 import { createTailscaleServers, watchCertReload } from '../lib/tailscale-https.js';
 import { certPaths } from '../lib/certPaths.js';
 import { createSidecarAuthGate } from '../lib/sidecarAuthGate.js';
+import { PM2_BIN, execPm2, DATA_DIR, INDEX_FILE, loadApps } from './shared.js';
 
 const __filename = fileURLToPath(import.meta.url);
 const __dirname = dirname(__filename);
 
-// Resolve PM2 binary to avoid pm2.cmd on Windows (creates visible CMD windows)
-const require = createRequire(import.meta.url);
-const PM2_BIN = join(dirname(require.resolve('pm2/package.json')), 'bin', 'pm2');
-
-/** Execute a PM2 CLI command via node (bypasses pm2.cmd) */
-function execPm2(pm2Args) {
-  return new Promise((resolve, reject) => {
-    const child = spawn(process.execPath, [PM2_BIN, ...pm2Args], { windowsHide: true });
-    let stdout = '';
-    let stderr = '';
-    child.stdout.on('data', (d) => { stdout += d.toString(); });
-    child.stderr.on('data', (d) => { stderr += d.toString(); });
-    child.on('close', (code) => {
-      if (code !== 0) return reject(new Error(stderr || `pm2 exited with code ${code}`));
-      resolve({ stdout, stderr });
-    });
-    child.on('error', reject);
-  });
-}
-
 const app = express();
 const PORT = process.env.PORT || 5560;
 
@@ -39,19 +19,6 @@ const PORT = process.env.PORT || 5560;
 const UI_TEMPLATE_FILE = join(__dirname, 'ui.template.html');
 const UI_HTML = await readFile(UI_TEMPLATE_FILE, 'utf8');
 
-// Paths
-const DATA_DIR = join(__dirname, '../data');
-const APPS_FILE = join(DATA_DIR, 'apps.json');
-const AUTOFIXER_DIR = join(DATA_DIR, 'autofixer');
-const INDEX_FILE = join(AUTOFIXER_DIR, 'index.json');
-
-// Load apps from PortOS
-async function loadApps() {
-  const data = await readFile(APPS_FILE, 'utf8').catch(() => '{"apps":{}}');
-  const parsed = JSON.parse(data);
-  return Object.entries(parsed.apps || {}).map(([id, app]) => ({ id, ...app }));
-}
-
 // Load autofixer history
 async function loadHistory() {
   const data = await readFile(INDEX_FILE, 'utf8').catch(() => '[]');
diff --git a/client/package-lock.json b/client/package-lock.json
index ab81293726..1a1ca1c90f 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -16,22 +16,21 @@
         "@xterm/addon-fit": "0.11.0",
         "@xterm/addon-web-links": "0.12.0",
         "@xterm/xterm": "6.0.0",
-        "lucide-react": "1.37.0",
+        "lucide-react": "1.40.0",
         "react": "19.2.8",
         "react-dom": "19.2.8",
         "react-router": "8.3.1",
         "recharts": "3.10.1",
         "socket.io-client": "4.8.3",
-        "three": "0.185.1",
-        "three-stdlib": "2.36.1"
+        "three": "0.185.1"
       },
       "devDependencies": {
-        "@biomejs/biome": "2.5.11",
+        "@biomejs/biome": "2.5.12",
         "@tailwindcss/postcss": "4.3.3",
         "@testing-library/dom": "10.4.1",
         "@testing-library/jest-dom": "7.0.1",
         "@testing-library/react": "16.3.3",
-        "@testing-library/user-event": "14.6.6",
+        "@testing-library/user-event": "14.6.7",
         "@vitejs/plugin-react": "6.1.1",
         "jsdom": "30.0.1",
         "rollup-plugin-visualizer": "7.1.1",
@@ -251,9 +250,9 @@
       }
     },
     "node_modules/@biomejs/biome": {
-      "version": "2.5.11",
-      "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.11.tgz",
-      "integrity": "sha512-Tj0dnkLPdW0ASjHfj2D/ZkkvPU2wrFmnE1jWTD2xzV1ycapV1DutbYXk4NDnR3rYTi1ZCbNFD4G2gRMEY65WaA==",
+      "version": "2.5.12",
+      "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.12.tgz",
+      "integrity": "sha512-Lw4VHZRebrReBBnlHa12JQjnIBm3JJAA55PDB9LbBVBF0q4RYphm6KfmIjqtPhf61MxZ5Q9KoK8R8x+7per5Aw==",
       "dev": true,
       "license": "MIT OR Apache-2.0",
       "bin": {
@@ -267,20 +266,20 @@
         "url": "https://opencollective.com/biome"
       },
       "optionalDependencies": {
-        "@biomejs/cli-darwin-arm64": "2.5.11",
-        "@biomejs/cli-darwin-x64": "2.5.11",
-        "@biomejs/cli-linux-arm64": "2.5.11",
-        "@biomejs/cli-linux-arm64-musl": "2.5.11",
-        "@biomejs/cli-linux-x64": "2.5.11",
-        "@biomejs/cli-linux-x64-musl": "2.5.11",
-        "@biomejs/cli-win32-arm64": "2.5.11",
-        "@biomejs/cli-win32-x64": "2.5.11"
+        "@biomejs/cli-darwin-arm64": "2.5.12",
+        "@biomejs/cli-darwin-x64": "2.5.12",
+        "@biomejs/cli-linux-arm64": "2.5.12",
+        "@biomejs/cli-linux-arm64-musl": "2.5.12",
+        "@biomejs/cli-linux-x64": "2.5.12",
+        "@biomejs/cli-linux-x64-musl": "2.5.12",
+        "@biomejs/cli-win32-arm64": "2.5.12",
+        "@biomejs/cli-win32-x64": "2.5.12"
       }
     },
     "node_modules/@biomejs/cli-darwin-arm64": {
-      "version": "2.5.11",
-      "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.11.tgz",
-      "integrity": "sha512-6SGZxoKbXvUjMn1t6A98HqWISPnGNbYs0R/Rt2JarmXBSev+lva4QxUMWEBX9lX1Wo1XTJ78uk5xVDtG58SRZg==",
+      "version": "2.5.12",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.12.tgz",
+      "integrity": "sha512-lCRY1rwgNeWNgTr4DI/u6ZwXTRwRLHAvbaio1YLLGS+4r1nhvB2ssyPqIpfUSmRveNfv0fn/N58C7CAdK2XVrg==",
       "cpu": [
         "arm64"
       ],
@@ -295,9 +294,9 @@
       }
     },
     "node_modules/@biomejs/cli-darwin-x64": {
-      "version": "2.5.11",
-      "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.11.tgz",
-      "integrity": "sha512-nYkXY7tLBEgnGbYapDKAyKzgt44ZEyG+AKalvTXtCWKYgepI9dw327q+cVgedxm+Udi1ZzHKUyZrIusHi/KQbw==",
+      "version": "2.5.12",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.12.tgz",
+      "integrity": "sha512-vhPgwnh+6tN3ArdAXuET99xaNbFt7CG82Bqn+omHVLC5xdVx45JsYjGPmUIGNzjDek5XdNCP1HKksK7fn8+3bQ==",
       "cpu": [
         "x64"
       ],
@@ -312,9 +311,9 @@
       }
     },
     "node_modules/@biomejs/cli-linux-arm64": {
-      "version": "2.5.11",
-      "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.11.tgz",
-      "integrity": "sha512-3PVLSTD9RR73rvVPt5G3T1gc+ycggWEGfTD7RvzzbtcDPD27NxgxBbAFfpm7DXJKW6VLHWE1lLMGvFt2Qxjcow==",
+      "version": "2.5.12",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.12.tgz",
+      "integrity": "sha512-2gp8aVwXYKdAtmBfRFCUuyDMcfN1ahHqUkGfLYrZlNRFmryMATLVvJgWKvyA8wu4Rwn5OSxM1UcUmOuOFNGeBQ==",
       "cpu": [
         "arm64"
       ],
@@ -332,9 +331,9 @@
       }
     },
     "node_modules/@biomejs/cli-linux-arm64-musl": {
-      "version": "2.5.11",
-      "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.11.tgz",
-      "integrity": "sha512-qhyZUMyCbWYFV2bAwRNVvfMVZ+hv7WYl6mossGrxC+uiQQXhvsuWWU8zz6jYX0mChZd9MgQZbm4vozTmG/5iGw==",
+      "version": "2.5.12",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.12.tgz",
+      "integrity": "sha512-couHYjFLL5uuI8ne6zhT7KwEsXo5YP7ry/2xmEqah7qanu0YmfDi3mwJg47YXSuv/NpZj22CZzcRH/5c4gjPSQ==",
       "cpu": [
         "arm64"
       ],
@@ -352,9 +351,9 @@
       }
     },
     "node_modules/@biomejs/cli-linux-x64": {
-      "version": "2.5.11",
-      "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.11.tgz",
-      "integrity": "sha512-JOytptlsgM33B2MMFUg8iBrb4IKpbD5JnJrSeYiaFEeAj4vuXx0iQSQZ4qK7sqyMtfjZxxPdNdMZZVL4y/mFyA==",
+      "version": "2.5.12",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.12.tgz",
+      "integrity": "sha512-SnvOs3TSTiuia4SQOUNe1aWC9RT4+YkjcKnOhL/nsKOV0k5ycgBkDzF0lUxKn1V7Q8CLTRq6iV23ZAivHomRoA==",
       "cpu": [
         "x64"
       ],
@@ -372,9 +371,9 @@
       }
     },
     "node_modules/@biomejs/cli-linux-x64-musl": {
-      "version": "2.5.11",
-      "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.11.tgz",
-      "integrity": "sha512-oRRlrchG5EfrEL/EmtT1qUjSNHk3/5LGeZhQqADBBAJF1b1ET6964xEKe7aGlGARzDfza8H/seEsFJl7S6Ql9w==",
+      "version": "2.5.12",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.12.tgz",
+      "integrity": "sha512-8A0oDW58/w9f/PQNYuq0sGUZtGtGrkNF4Z6n0PUoXpLCshi85vtKTv1XSznQawhdE4MXJ8ufpzHXyLFe87M/+w==",
       "cpu": [
         "x64"
       ],
@@ -392,9 +391,9 @@
       }
     },
     "node_modules/@biomejs/cli-win32-arm64": {
-      "version": "2.5.11",
-      "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.11.tgz",
-      "integrity": "sha512-e49E6K9hzH/ohJNx8Y26mY8HaV4I4ZViIeoqhKsmoXLKHhQnMeBAVqCgsGf2Wa3lXlS7RkporDXMHHWkzvZzFw==",
+      "version": "2.5.12",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.12.tgz",
+      "integrity": "sha512-b9vtoZFsuZt1pdjNwJvXl0f+BpayRzV008uS2+JpmwIKdSE2qdu4A/l04FESwLoou5g2E/Qlec0xwJydZplH+A==",
       "cpu": [
         "arm64"
       ],
@@ -409,9 +408,9 @@
       }
     },
     "node_modules/@biomejs/cli-win32-x64": {
-      "version": "2.5.11",
-      "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.11.tgz",
-      "integrity": "sha512-QSQr/KjOgXA7OzXJUWS+oguKyAZ3Q0l/lnlDGbu397eKo83atuWUjBPJrsqbKNF6CARGw8XXJLGzpHC8Ryhd4Q==",
+      "version": "2.5.12",
+      "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.12.tgz",
+      "integrity": "sha512-B1R/l+CwEpKFSuqiwePzPNRk1EiJN8kc0UhdafNz6MZN9v5OFP9HYP1irptvWzHrwVI4blVNGMbxc5zt70m3IA==",
       "cpu": [
         "x64"
       ],
@@ -2460,9 +2459,9 @@
       }
     },
     "node_modules/@testing-library/user-event": {
-      "version": "14.6.6",
-      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz",
-      "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==",
+      "version": "14.6.7",
+      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz",
+      "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -5118,9 +5117,9 @@
       }
     },
     "node_modules/lucide-react": {
-      "version": "1.37.0",
-      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.37.0.tgz",
-      "integrity": "sha512-LPsB4rD1TD6wZu1djKOf9vUnS1jTNaHbolXebXDgiTdb6jeA1agIJhJsIybCmjKmQClcOaal1o1OaiYahEftyQ==",
+      "version": "1.40.0",
+      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.40.0.tgz",
+      "integrity": "sha512-MaG+8WnOXkDWz9XeElj7TnQ890tTZUB0a36i03aRRCKGWE6e7jJpmdCvtxxuxcjjdqyN6m4sL6qlHVuSUDtYgg==",
       "license": "ISC",
       "peerDependencies": {
         "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -6154,9 +6153,9 @@
       }
     },
     "node_modules/postcss": {
-      "version": "8.5.26",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
-      "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+      "version": "8.5.27",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.27.tgz",
+      "integrity": "sha512-79Iho8QeYyooJ8e9lCRyTVlyTAkS/kXBYKff6TMzS3kEWGQ8Ds5UEtXpGrSUDLUWok6QTvxeYy0GO8fopHnaSA==",
       "funding": [
         {
           "type": "opencollective",
@@ -6173,7 +6172,7 @@
       ],
       "license": "MIT",
       "dependencies": {
-        "nanoid": "^3.3.17",
+        "nanoid": "^3.3.18",
         "picocolors": "^1.1.1",
         "source-map-js": "^1.2.1"
       },
diff --git a/client/package.json b/client/package.json
index 21eb30ebfc..21cef5a4b6 100644
--- a/client/package.json
+++ b/client/package.json
@@ -27,22 +27,21 @@
     "@xterm/addon-fit": "0.11.0",
     "@xterm/addon-web-links": "0.12.0",
     "@xterm/xterm": "6.0.0",
-    "lucide-react": "1.37.0",
+    "lucide-react": "1.40.0",
     "react": "19.2.8",
     "react-dom": "19.2.8",
     "react-router": "8.3.1",
     "recharts": "3.10.1",
     "socket.io-client": "4.8.3",
-    "three": "0.185.1",
-    "three-stdlib": "2.36.1"
+    "three": "0.185.1"
   },
   "devDependencies": {
-    "@biomejs/biome": "2.5.11",
+    "@biomejs/biome": "2.5.12",
     "@tailwindcss/postcss": "4.3.3",
     "@testing-library/dom": "10.4.1",
     "@testing-library/jest-dom": "7.0.1",
     "@testing-library/react": "16.3.3",
-    "@testing-library/user-event": "14.6.6",
+    "@testing-library/user-event": "14.6.7",
     "@vitejs/plugin-react": "6.1.1",
     "jsdom": "30.0.1",
     "rollup-plugin-visualizer": "7.1.1",
@@ -54,6 +53,7 @@
     "socket.io-parser": "4.2.7",
     "ws": "8.21.3",
     "nanoid": "3.3.18",
-    "three": "0.185.1"
+    "three": "0.185.1",
+    "postcss": "8.5.27"
   }
 }
diff --git a/client/public/ar-quick-look.svg b/client/public/ar-quick-look.svg
new file mode 100644
index 0000000000..efc3b2bfd5
--- /dev/null
+++ b/client/public/ar-quick-look.svg
@@ -0,0 +1,10 @@
+
+  Augmented reality
+  
+  
+  
+  
+  
+  
+  
+
diff --git a/client/src/AGENTS.md b/client/src/AGENTS.md
index a64d26fe0f..ec137b069b 100644
--- a/client/src/AGENTS.md
+++ b/client/src/AGENTS.md
@@ -7,12 +7,16 @@ These apply to React/Vite client code. Universal constraints (functional program
 - **No window.alert/confirm** - use inline confirmations or toast notifications
 - **Form labels need `htmlFor`/`id` pairing** - when adding a settings/config form field, wire `