diff --git a/docs/chezmoi/externals-and-ignore.mdx b/docs/chezmoi/externals-and-ignore.mdx
index 8d5016f..1cb1bc2 100644
--- a/docs/chezmoi/externals-and-ignore.mdx
+++ b/docs/chezmoi/externals-and-ignore.mdx
@@ -140,10 +140,11 @@ mise tool versions stay local to each machine and are not managed.
.local/bin/niri-launch
.config/fish/auto-Niri.fish
.local/bin/cp-appid
+.local/bin/projects-sync
{{ end }}
```
-The block is emitted, and these paths skipped, unless the OS is `linux` and the role is `desktop` or `laptop`. `mac` and `server` machines, and any non-Linux machine regardless of role, never receive the niri and Hyprland configs (`dot_config/niri/`, `dot_config/hypr/`), fontconfig (`dot_config/fontconfig/fonts.conf`), default-app associations (`dot_config/mimeapps.list`), the fish auto-start hook (`dot_config/fish/auto-Niri.fish`), the systemd user units (`dot_config/systemd/user/kitty-day-save.service`), or the `niri-launch`, `cp-appid` and `kitty-day-save` scripts. The `noctalia` and `niri-dynamic-workspaces` rules match nothing in the current source tree; they only take effect if those configs are added later.
+The block is emitted, and these paths skipped, unless the OS is `linux` and the role is `desktop` or `laptop`. `mac` and `server` machines, and any non-Linux machine regardless of role, never receive the niri and Hyprland configs (`dot_config/niri/`, `dot_config/hypr/`), fontconfig (`dot_config/fontconfig/fonts.conf`), default-app associations (`dot_config/mimeapps.list`), the fish auto-start hook (`dot_config/fish/auto-Niri.fish`), the systemd user units (`dot_config/systemd/user/kitty-day-save.service`, `projects-sync.service`, `projects-sync.timer`), or the `niri-launch`, `cp-appid`, `kitty-day-save` and `projects-sync` scripts. The `noctalia` and `niri-dynamic-workspaces` rules match nothing in the current source tree; they only take effect if those configs are added later.
| Machine | Desktop layer |
| --- | --- |
diff --git a/docs/chezmoi/scripts.mdx b/docs/chezmoi/scripts.mdx
index 93fc8fc..0c3c8b6 100644
--- a/docs/chezmoi/scripts.mdx
+++ b/docs/chezmoi/scripts.mdx
@@ -14,7 +14,7 @@ Six scripts run during `chezmoi apply`. Four live in `.chezmoiscripts/`, a direc
chezmoi writes the target state.
Install gh extensions.
Install and prune agent skills.
- Enable systemd user units on Linux (nirinit, kitty-day-save).
+ Enable systemd user units on Linux (nirinit, kitty-day-save, projects-sync.timer).
bat cache, font cache.
Rebuild bat cache when its theme or config changes.
Reinstall yazi plugins when the manifest changes.
@@ -94,6 +94,7 @@ A skipped run still exits 0, so chezmoi records this content as done and a plain
- If `~/.config/systemd/user/nirinit.service` exists and `systemctl` is available, runs `systemctl --user daemon-reload` and `systemctl --user enable nirinit.service`. Failures are ignored.
- Same check for `~/.config/systemd/user/kitty-day-save.service` (from `dot_config/systemd/user/`, so Linux desktop/laptop roles only), then `systemctl --user enable --now kitty-day-save.service`. See [kitty-day-save](/shell/scripts#kitty-day-save).
+- Same check for `~/.config/systemd/user/projects-sync.timer`, then `systemctl --user enable --now projects-sync.timer`. See [projects-sync](/shell/scripts#projects-sync).
- On non-Linux systems it renders to `#!/bin/sh` / `exit 0`.
Other personal automation units (brain-lens, omp-loop, rclone, hermes, voxtype) are deliberately not enabled; the script comment says to enable them by hand per machine. Their unit files are not in the repo, so they have to exist on the machine already.
diff --git a/docs/desktop/hyprland.mdx b/docs/desktop/hyprland.mdx
index a9430c4..c19170c 100644
--- a/docs/desktop/hyprland.mdx
+++ b/docs/desktop/hyprland.mdx
@@ -51,7 +51,7 @@ Omarchy's default bindings stay enabled. `omarchy_default_bindings` and `omarchy
| Workspace | Monitor |
| --- | --- |
-| 1 (default), 2, 3, 4 | `desc:Apple Computer Inc StudioDisplay 0x984262A7` (EDID match, survives connector changes) |
+| 1 (default), 2, 3, 4 | The main external display: `desc:Apple Computer Inc StudioDisplay 0x984262A7` if connected, else `desc:Samsung Electric Company S34CG50 HNTY501103` (Studio Display while neither is). Chosen at config load; `monitors.lua` reloads the config when the main external changes, and existing workspaces 1–4 are moved over |
| 5 (default), 6, 7, 8 | `eDP-1` |
`default` marks the workspace each monitor shows when it connects.
@@ -83,7 +83,7 @@ Scratch residents are placed `silent` so that [`omarchy-toggle-scratch`](#omarch
| --- | --- | --- |
| `elan9008:00-04f3:43c7-stylus` (Flow Z13 pen) | `eDP-1` | |
| `elan9008:00-04f3:43c7` (Flow Z13 touchscreen) | `eDP-1` | Taps land on the panel instead of the focused monitor |
-| `wacom-intuos-bt-s-pen` | `desc:Apple Computer Inc StudioDisplay 0x984262A7` | `active_area_size = { 152, 85.5 }`, `active_area_position = { 0, 0 }` — a 16:9 band cropped from the 16:10 tablet |
+| `wacom-intuos-bt-s-pen` | The main external display (Studio Display or Samsung ultrawide) | `active_area_size = { 152, 152 × height / width }` (85.5 on the Studio Display, 63.6 on the ultrawide), `active_area_position = { 0, 0 }` — a full-width band with the display's aspect ratio |
The layout, touchpad, and gesture examples in the file are commented out. Palm rejection is done by a libinput quirks file in `/etc`, which chezmoi does not manage.
@@ -120,12 +120,15 @@ Omarchy's stock fade (0.985 active / 0.96 inactive) is kept. Terminals get their
| Output | Mode | Position | Scale |
| --- | --- | --- | --- |
| any (fallback) | `preferred` | `auto` | 3.2 |
-| `eDP-1` (laptop) | `2560x1600@180` | `2560x0` | 2 |
+| `eDP-1` (laptop) | `2560x1600@180` | `auto-right` | 2 |
| `DP-4` (Apple Studio Display) | `5120x2880@60` | `0x0` | 2 |
+| `desc:Samsung Electric Company S34CG50 HNTY501103` (ultrawide, USB-C to HDMI from the iGPU; shows up as a `DP-n` connector) | `3440x1440@100` | `0x0` | 1 |
-`GDK_SCALE=3` is exported. The Studio Display sits left of the laptop panel. At scale 2 its logical width is 2560, so `eDP-1` starts at x = 2560.
+`GDK_SCALE=3` is exported. The external display sits left of the laptop panel; `auto-right` puts `eDP-1` at its logical width (2560 for the Studio Display, 3440 for the ultrawide), or at 0 when undocked. `monitors.lua` returns the "main external display" (Studio Display, else Samsung ultrawide) used by the workspace rules, the Wacom mapping, and `omarchy-toggle-scratch`, and reloads the config on hotplug when it changes.
-`~/.config/uwsm/env-hyprland` (source `dot_config/uwsm/env-hyprland`, Linux desktop/laptop only) is sourced by uwsm before Hyprland starts. It sets `AQ_DRM_DEVICES` to the AMD iGPU, resolved from `/dev/dri/by-path/pci-0000:c4:00.0-card`, so Hyprland never opens the Thunderbolt eGPU (RTX 5060 Ti, compute only). It leaves the variable unset if that path is missing.
+`~/.config/uwsm/env-hyprland` (source `dot_config/uwsm/env-hyprland`, Linux desktop/laptop only) is sourced by uwsm before Hyprland starts. It sets `AQ_DRM_DEVICES` to the AMD iGPU, resolved from `/dev/dri/by-path/pci-0000:c4:00.0-card`, so Hyprland never opens the Thunderbolt eGPU (RTX 5060 Ti, compute only: AI models, CUDA). Every display, the Samsung ultrawide included, hangs off the iGPU. It leaves the variable unset if that path is missing. It also sets `__EGL_VENDOR_LIBRARY_FILENAMES` to Mesa's `50_mesa.json`, so neither Hyprland nor session apps load `libEGL_nvidia` and hold `/dev/nvidiactl`; CUDA does not use EGL.
+
+Outside chezmoi, `/etc/modprobe.d/thunderbolt-egpu.conf` sets `options thunderbolt host_reset=0`. By default the thunderbolt driver resets the USB4 host router at boot, dropping the firmware-built PCIe tunnel while the early-loaded `nvidia` driver initialises the eGPU (Xid 79, GPU stuck until reboot).
## hyprsunset and portal
@@ -182,7 +185,7 @@ Every binding defined in `bindings.lua`. Bindings not listed here are Omarchy de
| --- | --- | --- |
| `SUPER + CTRL + J` | Next workspace (`e+1`) | — |
| `SUPER + CTRL + K` | Previous workspace (`e-1`) | Herdr keybindings |
-| `ALT + Q` / `W` / `E` / `R` | Switch to workspace 1 / 2 / 3 / 4 (Studio Display) | — |
+| `ALT + Q` / `W` / `E` / `R` | Switch to workspace 1 / 2 / 3 / 4 (main external display) | — |
| `ALT + Y` / `U` / `I` / `O` | Switch to workspace 5 / 6 / 7 / 8 (laptop panel) | — |
| `ALT + SHIFT + Q` / `W` / `E` / `R` / `Y` / `U` / `I` / `O` | Move window to workspace 1–8 | — |
| `SUPER + CTRL + S` | `omarchy-toggle-scratch scratchpad` | share menu |
@@ -249,7 +252,7 @@ The two `flea` bindings and a rule tagging `com.thisisgm.flea.picker` as `+float
| `SUPER + P` | Clipboard manager (`omarchy-shell shell toggle omarchy.clipboard`) | pseudo window |
| `ALT + TAB` | ScrollOverview `toggle all` (only when the plugin is loaded) | Omarchy's two `ALT + TAB` binds |
-`SUPER + SPACE` (Omarchy menu), `SUPER + ALT + SPACE` (apps menu), and `SUPER + SHIFT + RETURN` (browser) keep their Omarchy defaults.
+`SUPER + SPACE` (Omarchy menu) and `SUPER + ALT + SPACE` (apps menu) keep their Omarchy defaults. `SUPER + RETURN` (terminal) and `SUPER + SHIFT + RETURN` (browser) are unbound; terminals and browsers live on `SUPER + CTRL + ALT + J` / `K`.
### ScrollOverview plugin
@@ -285,7 +288,7 @@ Focuses an app's window, cycles to the next one on repeated presses, or launches
| `kitty-CLASS` | `^kitty-CLASS$` | `kitty --class kitty-CLASS` (command from the empty `KITTY_CMDS` map, if set) |
| `ghostty` | `ghostty` | `ghostty` |
| `browser` | zen, brave, chromium, google-chrome, firefox, librewolf, vivaldi, microsoft-edge, helium — excluding `chrome-`/`webapp-` web apps | `omarchy-launch-browser` |
-| `brave` | `^brave` (Brave Origin and its web apps) | `brave-origin` |
+| `brave` | `^brave` (Brave Origin and its web apps) | `omarchy-launch-browser` |
| `terminal` | kitty, alacritty, ghostty, foot, wezterm (Tern, the scratchpad terminal, does not match) | `omarchy-launch-terminal` (`xdg-terminal-exec`: the OS default terminal from `~/.config/xdg-terminals.list`, in the focused terminal's cwd) |
| `ai` | `claude\|hermes` | `hermes desktop` |
| anything else | `^APP$` | the argument as a command |
@@ -296,6 +299,14 @@ Bound to `SUPER + CTRL + ALT + H` (`ai`), `G` (`ghostty`), and `O` (`obsidian`).
One-line wrappers: `omarchy-cycle brave` and `omarchy-cycle terminal`. Bound to `SUPER + CTRL + ALT + K` and `SUPER + CTRL + ALT + J`.
+### omarchy-launch-browser
+
+```sh
+omarchy-launch-browser [--private] [url]
+```
+
+Shadows Omarchy's `/usr/bin/omarchy-launch-browser` (`~/.local/bin` comes first on `PATH`) and hands off to it. When Brave Origin is the default browser and not running, it prepends `--restore-last-session --hide-crash-restore-bubble`, so the first launch after a reboot or power-off reopens the last session's windows without the "Restore pages?" prompt. Chromium treats a killed browser as crashed and skips the restore; `--hide-crash-restore-bubble` clears that state. Private launches and launches into a running browser pass through unchanged.
+
### omarchy-cycle-window-size
```sh
@@ -353,10 +364,10 @@ omarchy-toggle-scratch [scratchpad|claude|fastmail|beeper|home|lmstudio]
| Profile | Special workspace | Home monitor | Residents (class → launch) |
| --- | --- | --- | --- |
-| `scratchpad` | `special:scratchpad` | Apple Studio Display | `so.stencil.tern` → `tern` |
-| `claude` | `special:claude` | Apple Studio Display | `com.anthropic.Claude` → `claude-desktop` |
-| `fastmail` | `special:fastmail` | focused | `com.fastmail.Fastmail` → `gtk-launch com.fastmail.Fastmail` |
-| `beeper` | `special:beeper` | focused | `Beeper` → `gtk-launch beeper` |
+| `scratchpad` | `special:scratchpad` | Studio Display, else Samsung ultrawide | `so.stencil.tern` → `tern` |
+| `claude` | `special:claude` | Studio Display, else Samsung ultrawide | `com.anthropic.Claude` → `claude-desktop` |
+| `fastmail` | `special:fastmail` | `eDP-1` | `com.fastmail.Fastmail` → `gtk-launch com.fastmail.Fastmail` |
+| `beeper` | `special:beeper` | `eDP-1` | `Beeper` → `gtk-launch beeper` |
| `home` | `special:home` | focused | `brave-home\.google\.com__.*` → `gtk-launch 'Google Home'` |
| `lmstudio` | `special:lmstudio` | focused | `ai\.elementlabs\.lmstudio` → `gtk-launch ai.elementlabs.lmstudio` |
diff --git a/docs/desktop/index.mdx b/docs/desktop/index.mdx
index 44a76dd..28baa86 100644
--- a/docs/desktop/index.mdx
+++ b/docs/desktop/index.mdx
@@ -6,7 +6,7 @@ sidebar:
---
:::note[Role gating]
-`.chezmoiignore.tmpl` skips the Linux desktop layer unless the machine runs Linux **and** its role is `desktop` or `laptop`. The gated targets are `~/.config/niri`, `~/.config/hypr`, `~/.config/noctalia`, `~/.config/niri-dynamic-workspaces`, `~/.config/fontconfig`, `~/.config/mimeapps.list`, `~/.config/systemd`, `~/.local/bin/kitty-day-save`, `~/.local/bin/niri-launch`, `~/.local/bin/cp-appid`, and `~/.config/fish/auto-Niri.fish`. On `mac` and `server` roles these are not applied.
+`.chezmoiignore.tmpl` skips the Linux desktop layer unless the machine runs Linux **and** its role is `desktop` or `laptop`. The gated targets are `~/.config/niri`, `~/.config/hypr`, `~/.config/noctalia`, `~/.config/niri-dynamic-workspaces`, `~/.config/fontconfig`, `~/.config/mimeapps.list`, `~/.config/systemd`, `~/.local/bin/kitty-day-save`, `~/.local/bin/niri-launch`, `~/.local/bin/cp-appid`, `~/.local/bin/projects-sync`, and `~/.config/fish/auto-Niri.fish`. On `mac` and `server` roles these are not applied.
`~/.config/waybar`, the `omarchy-*` scripts, the `niri-launch-ai` / `-browser` / `-terminal` / `-webapp` wrappers, and `~/.local/bin/tmux-niri-title` are **not** listed there, so chezmoi applies them on every role and OS. Only the exact path `.local/bin/niri-launch` is ignored, not the wrappers that call it.
:::
diff --git a/docs/shell/bash-zsh.mdx b/docs/shell/bash-zsh.mdx
index 95357f7..d06b92c 100644
--- a/docs/shell/bash-zsh.mdx
+++ b/docs/shell/bash-zsh.mdx
@@ -26,7 +26,7 @@ bash is part of the base system. zsh itself is not listed in `.chezmoidata/packa
Interactive shells only (`[[ $- != *i* ]] && return`). In order:
1. **PATH.** Prepends `~/.local/bin` if missing, sources `~/.cargo/env` if present, and appends `~/.maestro/bin` if it exists. Exports `GHQ_ROOT` and `CODE_ROOT` as `~/Code`.
-2. **Go.** `GOPATH=~/.local/share/go`, `GOBIN=$GOPATH/bin`, and `GOBIN` prepended to `PATH`. mise provides `GOROOT`.
+2. **Go.** `GOPATH=~/.local/share/go`, `GOBIN=$GOPATH/bin`, and `GOBIN` prepended to `PATH`. mise provides `GOROOT`. The same two values are also in `~/.config/go/env` (`dot_config/go/env.tmpl`), Go's own config file, so every `go` invocation uses them even without these variables (cron, `sudo -u`, `env -i`, GUI apps).
3. **Android SDK.** `ANDROID_HOME` and `ANDROID_SDK_ROOT` set to `~/.local/share/android`. `platform-tools`, `cmdline-tools/latest/bin`, and `emulator` are appended if they exist. Exports `GITHUB_TOKEN` from `gh auth token` unless it is already set.
4. **Aliases.** Sources `~/.config/shell/aliases.sh`.
5. **Functions.** Sources every `~/.config/bash/functions/*.bash`.
diff --git a/docs/shell/fish.mdx b/docs/shell/fish.mdx
index a735956..61591ed 100644
--- a/docs/shell/fish.mdx
+++ b/docs/shell/fish.mdx
@@ -88,7 +88,7 @@ Runs for every shell, including non-interactive ones, so scripts get the same `P
| `SHARP_IGNORE_GLOBAL_LIBVIPS` | `1` |
| `FZF_DEFAULT_OPTS` | cyberdream fzf theme (below) |
-`GOPATH`/`GOBIN` are not set here. The comment explains why: mise's fish hook re-applies its `[env]` on every prompt, so Go paths are pinned in `~/.config/mise/config.toml`, and mise provides `GOROOT`.
+`GOPATH`/`GOBIN` are not set here. The comment explains why: mise's fish hook re-applies its `[env]` on every prompt, so Go paths are pinned in `~/.config/mise/config.toml`, and mise provides `GOROOT`. `~/.config/go/env` holds the same values for any `go` run outside a shell.
```fish ~/.config/fish/conf.d/10-env.fish
set -gx FZF_DEFAULT_OPTS "
diff --git a/docs/shell/scripts.mdx b/docs/shell/scripts.mdx
index 5f3c91a..9e7c624 100644
--- a/docs/shell/scripts.mdx
+++ b/docs/shell/scripts.mdx
@@ -50,6 +50,7 @@ Several scripts below also exist as fish functions of the same name (`ai`, `c2p`
| [`mtime-sort`](#mtime-sort) | sh + perl | Sort path lines newest-first by mtime |
| [`omp-session`](#omp-session) | python3 | Resume or start omp for the current repo; backend of the `omp-sessions` cable |
| [`pj`](#pj) | bash | Task runner across every project task source, on a tv cable |
+| [`projects-sync`](#projects-sync) | bash | Symlink every `~/Code` repo into `~/Projects` |
| [`pj-palette`](#pj-palette) | bash | pj's tasks plus quick actions as a tmux-palette source |
| [`yazi-nvim-open`](#yazi-nvim-open) | sh | yazi opener that targets the host Neovim |
@@ -264,6 +265,18 @@ Lists `*.md` files up to 3 directories deep with `fd`. fzf shows an `mdfried` pr
**Needs:** `fd`, `fzf`, `mdfried`.
+## projects-sync
+
+```sh
+projects-sync
+```
+
+Mirrors every repo under the ghq root (`$GHQ_ROOT`, default `~/Code`, laid out `//`) as `~/Projects/` → the repo. Repos with the same name under different owners become `-`. Links pointing into `~/Code` whose repo is gone are removed; real files, real directories, and links pointing elsewhere in `~/Projects` are never touched.
+
+Run by `projects-sync.timer` (`dot_config/systemd/user/`, 30 s after login and every 5 minutes), which [`30-user-services`](/chezmoi/scripts#30-user-services) enables. ghq clones land two levels below `~/Code`, out of reach of a non-recursive `.path` unit, hence the poll.
+
+**Needs:** GNU `find`. Linux desktop/laptop roles only (`.chezmoiignore.tmpl`).
+
## mtime-sort
```sh