diff --git a/AGENTS.md b/AGENTS.md index 3686dc7fe..4f14a3b2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,16 @@ +**Claude seats (2026-09-22).** Two Claude Code logins live on the coordinator +and nowhere else: `cc` is `~/.claude` (the personal Claude Max login, config file +`~/.claude.json`), `cc2` is `~/.claude-work` (the leger.run Claude Max login, +config file `~/.claude-work/.claude.json`), selected only by `CLAUDE_CONFIG_DIR` +in the fish launchers (`home/dot_config/fish/config.fish`). Both share the same +skills and settings links; login, history, sessions and trust are per seat, so a +session id resumes only on the seat and from the cwd that created it +(`claude-sessions` fans out over the seats). Claude never saves workspace trust +for `$HOME`, so the launchers move into `$CLAUDE_ENVELOPE` (default `~/today`) +when typed from `~`; never start a seat in the home directory. Logins are hand +`/login`s, never a delivered secret (the old claude-credentials seed was removed +this day). The seat meters are `~/.local/state/tally-rewrite/meters/.json`. + **Physical seats (2026-09-16, supersedes older headless/client-only wording below).** Tom is returning the coordinator to primary-desktop duty with two upright LG 5K displays side by side at scale 2. Both coordinator and client have diff --git a/docs/theme-switcher-2026-09-17.md b/docs/theme-switcher-2026-09-17.md index 2da1b66f0..63db2c7c8 100644 --- a/docs/theme-switcher-2026-09-17.md +++ b/docs/theme-switcher-2026-09-17.md @@ -42,9 +42,10 @@ palette value does. | consumer | joins via | live reload on `theme ` | |---|---|---| | kitty | `include ${HOME}/.config/theme/kitty.conf` (kitty.conf) | `kitten @ --to unix:@kitty- load-config` per instance (sockets from `/proc/net/unix`) | +| ghostty (cmux Browser panes) | `config-file = ?/home/tom/.config/theme/ghostty` LAST in ghostty/config.ghostty (a config-file loads after its parent, so the fragment wins; absolute because Ghostty resolves relative paths against the symlinked config's dir) | nothing — libghostty reads the config when cmux opens a surface; new panes follow | | niri | `include optional=true "~/.config/theme/niri.kdl"` LAST in config.kdl; sections merge, later wins | `niri msg action load-config-file` | | herdr | `[theme] name = "terminal"`: every token is an ANSI slot | nothing — kitty reports the bg change via DEC 2031 (`CSI ?997;n`), herdr re-queries OSC 10/11/4 and repaints chrome + every pane | -| nvim | `lua/theme.lua` dofile()s `theme.lua`; catppuccin/bufferline/lualine read it | `nvim --server --remote-expr` → `require('theme').reload()` per instance | +| nvim | `~/.local/bin/nvim-lua/theme.lua` dofile()s `theme.lua`; catppuccin/bufferline/lualine read it | `nvim --server --remote-expr` → `require('theme').reload()` per instance | | fish | `conf.d/colors.fish` sources the fragment; re-sources at the next prompt when the pointer moved | automatic (no universal variables: `fish_variables` is tracked) | | starship | already ANSI-named; the one `#F47B85` became `red` | follows kitty | | Claude Code | `theme` key in `~/.claude.json` ← `meta` `claude_code=` | written tmp+rename; takes effect on next start | diff --git a/home/dot_config/fish/config.fish b/home/dot_config/fish/config.fish index 35b0df12b..5fa17cde4 100644 --- a/home/dot_config/fish/config.fish +++ b/home/dot_config/fish/config.fish @@ -42,13 +42,43 @@ alias l.="eza -a | grep -E \"^\.\"" alias cd='z' # zi command -# Claude Code -alias cc='claude --dangerously-skip-permissions' -alias cac='claude --continue --dangerously-skip-permissions' -# Second Claude account (work). CLAUDE_CONFIG_DIR isolates login, history and -# state in ~/.claude-work; skills/settings are the same dotfiles links (home.nix). -alias cc2='env CLAUDE_CONFIG_DIR=$HOME/.claude-work claude --dangerously-skip-permissions' -alias cac2='env CLAUDE_CONFIG_DIR=$HOME/.claude-work claude --continue --dangerously-skip-permissions' +# Claude Code: two seats, one launcher shape (2026-09-22). +# cc / cac -> ~/.claude the personal Claude Max login (gmail org) +# cc2 / cac2 -> ~/.claude-work the leger.run Claude Max login +# CLAUDE_CONFIG_DIR isolates login, history and state per seat; skills and +# settings are the same dotfiles links for both (home.nix). TALLY_SEAT names the +# seat to hooks and receipts. Claude Code never saves workspace trust for $HOME +# (docs, security: "trust acceptance is held for the current session only and +# is not written to disk"), so a seat started in ~ shows "Accessing workspace" +# on every launch. Both launchers therefore move into the desk envelope when +# typed from ~; the shell stays there afterwards, which is where the desk +# session's files belong (~/today/CLAUDE.md section 4). +set -q CLAUDE_ENVELOPE; or set -gx CLAUDE_ENVELOPE $HOME/today +function __claude_seat --description 'start one Claude seat outside $HOME' + set -l seat $argv[1] + set -l config_dir $argv[2] + set -l args $argv[3..] + if test (pwd) = $HOME + if not test -d $CLAUDE_ENVELOPE + echo "$seat: \$CLAUDE_ENVELOPE ($CLAUDE_ENVELOPE) does not exist; cd into a project first" >&2 + return 1 + end + builtin cd $CLAUDE_ENVELOPE + echo "($seat: started in $CLAUDE_ENVELOPE, not in ~)" >&2 + end + # A terminal opened from inside a cc2 session already carries + # CLAUDE_CONFIG_DIR, so `cc` must clear it explicitly or it silently starts + # cc2 (measured 2026-09-22). Each seat sets its own value or none. + if test -n "$config_dir" + env CLAUDE_CONFIG_DIR=$config_dir TALLY_SEAT=$seat claude --dangerously-skip-permissions $args + else + env -u CLAUDE_CONFIG_DIR TALLY_SEAT=$seat claude --dangerously-skip-permissions $args + end +end +function cc; __claude_seat cc "" $argv; end +function cac; __claude_seat cc "" --continue $argv; end +function cc2; __claude_seat cc2 $HOME/.claude-work $argv; end +function cac2; __claude_seat cc2 $HOME/.claude-work --continue $argv; end # Third Claude account (2026-09-05), same rotation: state in ~/.claude-3. alias cc3='env CLAUDE_CONFIG_DIR=$HOME/.claude-3 claude --dangerously-skip-permissions' alias cac3='env CLAUDE_CONFIG_DIR=$HOME/.claude-3 claude --continue --dangerously-skip-permissions' diff --git a/home/dot_config/ghostty/config.ghostty b/home/dot_config/ghostty/config.ghostty new file mode 100644 index 000000000..bbca8b32f --- /dev/null +++ b/home/dot_config/ghostty/config.ghostty @@ -0,0 +1,83 @@ +# ghostty — kitty.conf's twin (2026-09-17). +# +# The only Ghostty on the fleet today is libghostty inside cmux Browser's +# terminal panes (cmux-tui; ~/.local/opt/cmux-browser-nightly, not packaged yet). +# Its settings page's "Use Ghostty config" reads THIS file, +# $XDG_CONFIG_HOME/ghostty/config.ghostty. Every value below mirrors kitty.conf +# key for key, so a cmux pane looks like a kitty window — a kitty change +# belongs here too. Where Ghostty has no equivalent, the kitty key is named +# and the gap stated. The colours are NOT here: they join through the theme +# fragment at the bottom, like kitty's `include ${HOME}/.config/theme/kitty.conf`. + +# shell fish — kitty.conf says why: the login shell is bash fleet-wide (#236), +# fish stays the terminal UI. +command = fish +shell-integration = fish + +# Font — kitty's EXPLICIT families (2026-08-21): Liga SFMono Nerd Font, bold is +# SemiBold on purpose (the weight the fontconfig fallback actually served). +# Ghostty takes family + style, where kitty takes the full face name. +font-family = Liga SFMono Nerd Font +font-family-bold = Liga SFMono Nerd Font +font-style-bold = SemiBold +font-family-italic = Liga SFMono Nerd Font +font-style-italic = Italic +font-family-bold-italic = Liga SFMono Nerd Font +font-style-bold-italic = SemiBold Italic +# font_size 16.0 (2026-09-16): 14 + one legacy ctrl+= press. +font-size = 16 +# disable_ligatures never: ligatures are Ghostty's default, nothing to set. + +# Cursor: cursor_blink_interval 0. kitty's cursor_trail has no Ghostty +# equivalent (cursor_trail_color in the fragment is kitty-only). +cursor-style = block +cursor-style-blink = false + +# window_padding_width 10 +window-padding-x = 10 +window-padding-y = 10 + +# detect_urls yes / open_url_with default (xdg-open here too). +# underline_hyperlinks always: Ghostty underlines a link on hover only. +link-url = true +# confirm_os_window_close 0 +confirm-close-surface = false +# scrollback_lines 100000 — Ghostty caps scrollback in BYTES per surface +# (lazily allocated, compressed when idle): 100 MB ≈ 100k lines of 1 KB. +scrollback-limit = 100000000 +# enable_audio_bell no — Ghostty's default rings the system bell; cmux may +# still flag the surface (attention/title), never sound. +bell-features = no-system,no-audio +# copy_on_select clipboard +copy-on-select = clipboard +# THE THIN-CLIENT CLIPBOARD CONTRACT (kitty.conf, 2026-09-11), the OSC 52 +# half: a remote program may WRITE this seat's clipboard (write-clipboard) and +# a remote READ prompts (read-clipboard-ask). `clipboard-read = allow` would +# let anything the coordinator runs exfiltrate the clipboard silently, so it +# must never be set. +clipboard-write = allow +clipboard-read = ask + +# map ctrl+equal / ctrl+minus — kitty's legacy increase_font_size step is a +# fixed +2.0pt (0.48.0 boss.py); Ghostty's default step is 1, so pass the amount. +keybind = ctrl+equal=increase_font_size:2 +keybind = ctrl+minus=decrease_font_size:2 +# kitty_mod+h / kitty_mod+g (kitty-scrollback.nvim) and enabled_layouts splits +# have no Ghostty side: cmux owns splits, tabs and the scrollback viewer. + +# Frosted terminal (2026-09-16): background_opacity 0.96. The compositor blur +# rides on the kitty app-ids in niri's window-rules.kdl; cmux is a different +# app-id, so its panes are translucent without the frost until a rule lands. +background-opacity = 0.96 +# kitty does not dim inactive splits; Ghostty's default is 0.7. +unfocused-split-opacity = 1 + +# Colours: the theme fragment home/theme.nix renders from home/themes/*.nix +# under ~/.config/themes//ghostty, joined through the ~/.config/theme +# pointer that ~/.local/bin/theme retargets. Ghostty resolves a relative +# config-file against the directory of THIS file — which is a symlink into the +# checkout — so the path is absolute; $HOME is /home/tom on every host +# (home.homeDirectory). `?` = no error while the fragment is not rendered yet. +# A config-file loads AFTER the file that names it, so the fragment wins. +# No live reload: cmux reads the config when it opens a surface. +config-file = ?/home/tom/.config/theme/ghostty diff --git a/home/dot_config/nvim/lua/plugins/lualine.lua b/home/dot_config/nvim/lua/plugins/lualine.lua index f6cd7d5c6..c8b2bf9e5 100644 --- a/home/dot_config/nvim/lua/plugins/lualine.lua +++ b/home/dot_config/nvim/lua/plugins/lualine.lua @@ -141,7 +141,10 @@ components.location = { function M.setup() -- Palette colours are resolved here, not at module load, so theme.reload() -- can call setup() again after a `theme` switch. - local T = require('theme').palette() + -- pcall: a missing theme module must never cost the status line (noir + -- fallback values, the same as theme.lua's own). + local ok, theme = pcall(require, 'theme') + local T = ok and theme.palette() or { green = '#9BE963', red = '#F47B85' } components.external_changes.color.fg = T.green components.recording.color.fg = T.red require('lualine').setup({ diff --git a/home/dot_config/nvim/lua/theme.lua b/home/dot_local/bin/nvim-lua/theme.lua similarity index 90% rename from home/dot_config/nvim/lua/theme.lua rename to home/dot_local/bin/nvim-lua/theme.lua index 7a7180ec7..44acfa176 100644 --- a/home/dot_config/nvim/lua/theme.lua +++ b/home/dot_local/bin/nvim-lua/theme.lua @@ -1,11 +1,19 @@ --- lua/theme.lua — the palette every colour in this config comes from. +-- theme.lua — the palette every colour in the nvim config comes from. -- -- Home Manager renders ~/.config/themes//theme.lua for each theme -- (home/themes/*.nix); ~/.config/theme is the symlink ~/.local/bin/theme -- retargets. This module dofile()s the current fragment and applies it to -- catppuccin, bufferline and lualine — at startup from plugins.lua.in, and -- again on `theme `, which calls reload() over --remote-expr on every --- running nvim. RAW (live-edit), see home/nvim.nix nvimRaw. +-- running nvim. +-- +-- Lives in ~/.local/bin/nvim-lua/, not ~/.config/nvim/lua/ (2026-09-17): that +-- directory is ONE out-of-store link into the checkout (home.nix +-- `home.file.".local/bin"`) and init.lua adds it to package.path before +-- plugins load, so require('theme') resolves the moment the checkout has this +-- file. Under nvim/lua/ it needed its own per-file link from a rebuild, and +-- lualine.lua (already linked) failed with "module 'theme' not found" on any +-- machine whose checkout was ahead of its switch. local M = {} local FRAGMENT = vim.fn.expand('~/.config/theme/theme.lua') diff --git a/home/home.nix b/home/home.nix index b63c9ee47..023f07d84 100644 --- a/home/home.nix +++ b/home/home.nix @@ -69,6 +69,7 @@ let configDirs = [ "niri" "kitty" + "ghostty" # kitty.conf's twin, read by libghostty in cmux Browser's panes "fish" "starship" "yt-dlp" diff --git a/home/nvim.nix b/home/nvim.nix index fe1482ef4..7d2b15649 100644 --- a/home/nvim.nix +++ b/home/nvim.nix @@ -180,8 +180,9 @@ let "lua/plugins/nvim-tree.lua" "lua/plugins/telescope.lua" "lua/plugins/lualine.lua" - # the theme switcher's palette loader (docs/theme-switcher-2026-09-17.md) - "lua/theme.lua" + # NOT lua/theme.lua: the theme switcher's palette loader lives in + # ~/.local/bin/nvim-lua/ (a whole-dir RAW link on init.lua's package.path), + # so a checkout pulled ahead of its switch still finds it. See its header. ]; # every binary the nvim config shells out to (replaces mason; covers all plugin CLIs) diff --git a/home/themes/default.nix b/home/themes/default.nix index 1fe24ea63..9d87628bb 100644 --- a/home/themes/default.nix +++ b/home/themes/default.nix @@ -68,6 +68,48 @@ let inactive_border_color ${t.ground.base} ''; + # Ghostty (libghostty inside cmux Browser's terminal panes): the kitty + # fragment, key for key, in Ghostty's `key = value` grammar. + ghostty = t: '' + ${header t "#"} + # Joined by ghostty/config.ghostty's `config-file = ?/home/tom/.config/theme/ghostty`. + background = ${t.ground.base} + foreground = ${t.fg} + selection-background = ${t.selection.bg} + selection-foreground = ${t.selection.fg} + cursor-color = ${t.cursor} + cursor-text = ${t.cursorText} + + # black + palette = 0=${t.ansi.black} + palette = 8=${t.ansi.brightBlack} + # red + palette = 1=${t.red} + palette = 9=${t.red} + # green + palette = 2=${t.green} + palette = 10=${t.green} + # yellow + palette = 3=${t.yellow} + palette = 11=${t.yellow} + # blue + palette = 4=${t.blue} + palette = 12=${t.blue} + # magenta + palette = 5=${t.magenta} + palette = 13=${t.magenta} + # cyan + palette = 6=${t.cyan} + palette = 14=${t.cyan} + # white + palette = 7=${t.ansi.white} + palette = 15=${t.ansi.brightWhite} + + # splits: as kitty, the divider is niri's INACTIVE window border. + split-divider-color = ${t.border.inactive} + unfocused-split-fill = ${t.ground.base} + ''; + niri = t: '' ${header t "//"} // Joined by niri/config.kdl's `include optional=true "~/.config/theme/niri.kdl"`, @@ -168,6 +210,7 @@ rec { render = t: { "kitty.conf" = kitty t; + "ghostty" = ghostty t; "niri.kdl" = niri t; "colors.fish" = fish t; "theme.lua" = lua t; diff --git a/hosts/coordinator/disko.nix b/hosts/coordinator/disko.nix index 7140edc8e..e0f067d0d 100644 --- a/hosts/coordinator/disko.nix +++ b/hosts/coordinator/disko.nix @@ -64,6 +64,17 @@ # Wiped blank before the physical move (no partition table, no bootloader), # then laid out here. # + # ── OWNERSHIP (Tom, 2026-09-19) ───────────────────────────────────────────── + # THIS DISK BELONGS TO SODIMO. It is one of exactly two sodimo-owned items in + # the fleet, the other being the `worker` chassis. It is here only because the + # 2026-08-30 transition fitted Tom's own 1TB (26051Y809195) into worker and + # moved this one across. + # + # It LEAVES with worker (2+ months out as of 2026-09-19; per FRONT-10 no agent + # invents or enforces that date). /home lives on it, so that departure is a + # migration off this disk, not an unplug — plan it as one. Afterwards the + # coordinator runs on its own 1TB anchor 25140U804698 with no secondary. + # # ── The rule this disk exists to enforce ──────────────────────────────────── # The 1TB anchor holds the OS and only the OS: the nix store and everything # NixOS derives from it, /etc, /var, and the local-models weight collection. diff --git a/hosts/nas/disko.nix b/hosts/nas/disko.nix index 87df34a1e..6736258f4 100644 --- a/hosts/nas/disko.nix +++ b/hosts/nas/disko.nix @@ -42,6 +42,16 @@ # generated media (media.nix). Random-I/O state belongs here: it would be # wear on the eMMC and seek chatter on the HDD. The mountpoint move was # non-destructive — same filesystem, never reformatted. + # + # ── In the 2026-09-19 disk shuffle ────────────────────────────────────────── + # This Fanxiang is Tom's and is a CANDIDATE TO MOVE to the fanless desk NUC: + # 256GB is ample for a seat that only runs Chromium. In that option the 1TB + # freed by the worker return lands here instead, taking /mnt/fast to 1TB. + # The alternative sends the freed 1TB straight to the NUC and leaves this + # disk alone. Undecided as of 2026-09-19; either way nothing is purchased. + # If this disk does move, /mnt/fast is offline in the interim — it was 49% + # of 234G at the 2026-09-18 census and carries PostgreSQL, Navidrome state, + # Immich generated media and the remote journal. Check those before moving. disko.devices.disk.journal = { type = "disk"; device = "/dev/disk/by-id/nvme-Fanxiang_S500Pro_256GB_26040259615000015"; diff --git a/hosts/worker/disko.nix b/hosts/worker/disko.nix index 0f8f06405..9fd20028d 100644 --- a/hosts/worker/disko.nix +++ b/hosts/worker/disko.nix @@ -6,6 +6,22 @@ # 500GB paired against the coordinator's 1TB was the fleet's dual-node # capacity ceiling. # + # ── OWNERSHIP (Tom, 2026-09-19) ───────────────────────────────────────────── + # THIS DISK IS TOM'S, NOT SODIMO'S. sodimo owns exactly two items in this + # fleet: the `worker` chassis and the 500GB SN7100 260538801482 — and that + # 500GB is fitted in the COORDINATOR today, not here. When worker goes back + # (2+ months out as of 2026-09-19; per FRONT-10 no agent invents or enforces + # that date), the 500GB returns to this chassis and THIS 1TB COMES OUT. + # + # Do not read "worker's boot disk" as "unavailable". That is a filesystem + # fact, not an ownership fact, and conflating the two sent the silent-desk + # planning down a wrong path until it was corrected on 2026-09-19. + # + # The freed 1TB is the fleet's ENTIRE NVMe surplus. It is spoken for: either + # it becomes the fanless desk NUC's disk, or it upgrades the NAS /mnt/fast + # (256GB -> 1TB) and the NAS's Fanxiang 256GB goes to the NUC instead. + # Undecided. Standing constraint (Tom, 2026-09-19): NO NEW M.2 IS PURCHASED. + # # ── Why the attr is `w1t` and NOT `main` ──────────────────────────────────── # disko derives GPT partition NAMES from the attr: `main` yields # `disk-main-ESP` / `disk-main-root`. The coordinator's anchor uses those same diff --git a/modules/secrets.nix b/modules/secrets.nix index a9ec1e9af..135c0bd19 100644 --- a/modules/secrets.nix +++ b/modules/secrets.nix @@ -23,35 +23,12 @@ in config = lib.mkIf cfg.enable ( lib.mkMerge [ - # Claude Code OAuth credential — coordinator ONLY, and the recipient tier in - # ../secrets.nix now matches (aug04 ruling), so this MUST stay host-gated: - # no other host can decrypt the ciphertext, and declaring an undecryptable - # secret fails activation. The zenbook was already excluded (jul12 ruling: - # the laptop is a standalone backup operator for when the coordinator is - # unreachable, so it logs in with its OWN fresh OAuth session instead of - # inheriting the coordinator's token — two devices refreshing one shared - # token can race and sign each other out); the nas joined it aug04 for the - # simpler reason that it has no `claude` and never spent the token. - (lib.mkIf (config.networking.hostName == "coordinator") { - age.secrets.claude-credentials = { - file = ../secrets/claude-credentials.age; - owner = "tom"; - group = "users"; - mode = "600"; - }; - - # Seed the Claude Code OAuth credential once into a WRITABLE path Claude owns — - # agenix delivers a read-only /run/agenix symlink, but Claude must rewrite the - # file on token refresh, so copy rather than link, and only if absent. - system.userActivationScripts.seedClaudeCreds.text = '' - cred="$HOME/.claude/.credentials.json" - if [ ! -e "$cred" ] && [ -r "${config.age.secrets.claude-credentials.path}" ]; then - mkdir -p "$HOME/.claude" - cp "${config.age.secrets.claude-credentials.path}" "$cred" - chmod 600 "$cred" - fi - ''; - }) + # (claude-credentials — the Claude Code OAuth token seeded into + # ~/.claude/.credentials.json — was declared and copied here until + # 2026-09-22. The token had been dead since its August 4 rotation, and a + # seed that fires whenever the file is absent can only ever revert a fresh + # `/login` after a rebuild. Both Claude seats now log in by hand, once, + # on the coordinator: cc into ~/.claude, cc2 into ~/.claude-work.) # NOT ungated any more (2026-08-28). This block delivers ssh-user-key and # atuin-key, whose ciphertexts are encrypted to the `delivered` tier — and diff --git a/secrets.nix b/secrets.nix index 9327bae0a..5d94f6b0b 100644 --- a/secrets.nix +++ b/secrets.nix @@ -150,14 +150,8 @@ in # cleanup — as agenix ciphertext it survives reflash and never needs re-minting. "secrets/immich-api-key.age".publicKeys = editors ++ coordinatorOnly; - # Claude Code OAuth credential. Demoted from the common tier to coordinator-only - # (2026-08-04, Tom's ruling): the nas holds no `claude` binary and never ran an - # agent, so it had no use for the token; zenbook-duo was already excluded from - # delivery (jul12 ruling — it logs in with its OWN session, since two devices - # refreshing one shared token race and sign each other out). That left the - # coordinator as the only real consumer, so the recipient tier now says so — - # a token this wide should not be decryptable by boxes that never spend it. - "secrets/claude-credentials.age".publicKeys = editors ++ coordinatorOnly; + # (claude-credentials.age removed 2026-09-22: the seat logins are hand + # `/login`s on the coordinator, never a delivered secret; see modules/secrets.nix.) # Brother HL-L2445DW Web Based Management admin password, set 2026-08-21 when # the printer's forced default-password change gated its move onto the thomas diff --git a/secrets/claude-credentials.age b/secrets/claude-credentials.age deleted file mode 100644 index 8d0a443d1..000000000 Binary files a/secrets/claude-credentials.age and /dev/null differ