{ config, pkgs, lib, ... }: { config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { # Create symlink from home to storage systemd.tmpfiles.rules = [ "L+ /home/fred/storage - - - - /mnt/storage" ]; # Basic system packages environment.systemPackages = with pkgs; [ mergerfs wget util-linux javaPackages.compiler.temurin-bin.jre-25 unzip screen yt-dlp ghostty.terminfo usbutils lm_sensors (pkgs.writeShellScriptBin "transcode-hevc" '' export PATH="${pkgs.jellyfin-ffmpeg}/bin:${pkgs.coreutils}/bin:${pkgs.findutils}/bin:${pkgs.gnugrep}/bin:${pkgs.gawk}/bin:${pkgs.bc}/bin:${pkgs.curl}/bin:$PATH" exec ${pkgs.bash}/bin/bash ${../scripts/transcode-hevc.sh} "$@" '') (pkgs.writeShellScriptBin "record-update" '' export PATH="${pkgs.nvd}/bin:${pkgs.coreutils}/bin:${pkgs.gnugrep}/bin:${pkgs.gnused}/bin:$PATH" exec ${pkgs.bash}/bin/bash ${../scripts/record-update.sh} "$@" '') # Stats stream for the quickshell server monitor on the desktops: # `ssh mediaserver qs-stats` = top's 2s batch stream, interleaved with # one @STAT line per tick (hottest coretemp across both sockets, WAN # byte rates from eno1). Everything rides one SSH connection. (pkgs.writeShellScriptBin "qs-stats" '' # Single writer: the loop re-emits top's lines itself and injects a # @STAT line at each frame header. top writing the pipe directly in # parallel raced the injected lines (pipe writes aren't line-atomic) # and spliced @STAT mid-frame, where the client parser never saw it. C=${pkgs.coreutils}/bin export LC_ALL=C prev_rx="" ${pkgs.procps}/bin/top -b -d 2 -w 512 | while IFS= read -r line; do printf '%s\n' "$line" case $line in "top - "*) t=0 for h in /sys/class/hwmon/*; do [ "$($C/cat "$h/name" 2>/dev/null)" = coretemp ] || continue for f in "$h"/temp*_input; do v=$($C/cat "$f" 2>/dev/null || echo 0) [ "$v" -gt "$t" ] && t=$v done done read -r rx tx < <(${pkgs.gawk}/bin/awk '$1 == "eno1:" {print $2, $10}' /proc/net/dev) if [ -n "$prev_rx" ]; then printf '@STAT temp=%s rxbps=%s txbps=%s\n' "$((t / 1000))" "$(( (rx - prev_rx) / 2 ))" "$(( (tx - prev_tx) / 2 ))" fi prev_rx=$rx prev_tx=$tx ;; esac done '') # Instant-answer backend for the quickshell launcher on the desktops: # `printf '%s' "question" | ssh mediaserver qs-ask`. # # Runs through Claude Code so it draws on fred's existing subscription — # no API key, no separate billing. The trade is latency: Claude Code boots # a Node process per question, so this takes ~6s against the raw API's # ~1s. The launcher therefore shows its Wikipedia answer first and lets # this one supersede it when it lands. # # Reads the question from stdin — passing it as an ssh argv element would # send it through the remote shell for a second round of word splitting. # # Every flag here is either isolation or trimming the invocation: # --safe-mode no CLAUDE.md, skills, hooks, plugins, MCP or # custom agents. Critical for correctness, not # just speed: fred's ~/.claude/CLAUDE.md tells # Claude to answer in caveman mode, which would # otherwise leak into launcher answers. Auth # keeps working normally, which is what makes # the subscription usable here. # --no-session-persistence writes no transcript, so launcher questions # never appear in /resume or any project history # --tools "" no tools at all: nothing to load, and it # cannot touch the filesystem or network # --disable-slash-commands no skill resolution # --strict-mcp-config ignore every MCP config (none supplied) # --system-prompt replaces the full Claude Code system prompt # with one line, rather than appending to it # --permission-mode dontAsk never block waiting on a prompt nobody sees # --model claude-haiku-4-5 smallest model — a one-line fact needs no more # # NOT --bare, which looks like the right flag and is a trap: it skips even # more, but its auth is "strictly ANTHROPIC_API_KEY or apiKeyHelper (OAuth # and keychain are never read)" — i.e. it cannot use the subscription. # # STDOUT is the launcher's channel: the answer, or nothing at all if the # call fails or the model doesn't know — silence is what the launcher # reads as "keep the Wikipedia answer". Every reason for that silence goes # to STDERR instead, which the launcher discards and a human running # `qs-ask` by hand can read — and to the journal, since over ssh nobody # ever sees that STDERR. `journalctl -t qs-ask` is the only way to tell a # blank card caused by UNKNOWN from one caused by a failed call. (pkgs.writeShellScriptBin "qs-ask" '' # Every line carries this script's pid, because the launcher fires a # fresh call per debounce and concurrent runs otherwise interleave with # no way to pair a question with its outcome. The pid goes in the # message text rather than through logger's --id: --id sets SYSLOG_PID, # which journalctl does not display — it shows the pid of the logger # process itself, a different number on every line. log() { printf 'qs-ask: %s\n' "$*" >&2 ${pkgs.util-linux}/bin/logger -t qs-ask -- "[$$] $*" } q=$(${pkgs.coreutils}/bin/cat) [ -z "$q" ] && { log "no question on stdin"; exit 0; } log "ask: $q" # Claude Code derives its project state (transcripts, auto-memory) from # the working directory, so run from a dedicated empty one. Combined with # --no-session-persistence and --safe-mode, a launcher question leaves no # trace in the real projects' history or memory. work="$HOME/.cache/qs-ask" ${pkgs.coreutils}/bin/mkdir -p "$work" || { log "cannot create $work"; exit 0; } cd "$work" || { log "cannot enter $work"; exit 0; } # MAX_THINKING_TOKENS=0 is the single biggest win here, worth ~2.4x. # Measured: Haiku defaults to extended thinking, and it spent 225 output # tokens and 1.9s deliberating before emitting an 80-character answer # about the height of a tower. With thinking off: 43 output tokens, # time-to-first-text 2657ms -> 1122ms, wall clock 5.7s -> 2.4s, same # answer. A one-line lookup has nothing to reason about. # # --name is not cosmetic: without it Claude Code fires a second, # concurrent `source=generate_session_title` request to invent a title # for a session --no-session-persistence then throws away. It costs no # wall time (both requests dispatch ~1ms apart), but it spent 507 input # tokens against 153 for the real question. Naming the session up front # removes it: one request instead of two, 660 input tokens -> 153. # # For the record, two things that are NOT the bottleneck, both measured: # process startup (0.12s boot + 28ms to fire the request, so keeping a # warm process saves nothing) and prompt size (184 input tokens). ans=$(printf '%s' "$q" | ${pkgs.coreutils}/bin/env MAX_THINKING_TOKENS=0 \ ${pkgs.claude-code}/bin/claude -p \ --safe-mode \ --no-session-persistence \ --tools "" \ --disable-slash-commands \ --strict-mcp-config \ --permission-mode dontAsk \ --output-format text \ --model claude-haiku-4-5 \ --name qs-ask \ --system-prompt 'Answer the question in one or two short sentences, under 240 characters. Lead with the specific fact asked for, including units. The query is often bare keywords rather than a question ("shadow hunter knife classic wow"); treat that as a request for the key facts about that thing, not as unanswerable. No preamble, no caveats, no markdown, no follow-up offers. Reply with exactly UNKNOWN only when you genuinely do not know, or the answer depends on live data you do not have.' \ 2>/dev/null) \ || { log "claude exited non-zero (auth expired? run 'claude' once to log in)"; exit 0; } ans=$(printf '%s' "$ans" | ${pkgs.coreutils}/bin/tr -d '\r' | ${pkgs.gnused}/bin/sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') case "$ans" in "") log "empty answer"; exit 0 ;; UNKNOWN*) log "model replied UNKNOWN"; exit 0 ;; esac log "ok: $ans" printf '%s' "$ans" '') ]; # Basic networking networking.useDHCP = lib.mkForce false; # Allow fred to act as a remote Nix builder (trusted users can import # unsigned store paths sent by the build client). nix.settings.trusted-users = [ "root" "fred" ]; # Automatic daily system updates system.autoUpgrade = { enable = true; flake = "git+https://forg.gregersen.it/rope/nixos"; dates = "05:15"; allowReboot = true; }; # WAN exposure is controlled by nftables in services/router.nix + # ports.toml (networking.firewall is disabled on this host). services.openssh = { enable = true; settings = { PermitRootLogin = "no"; PasswordAuthentication = false; }; }; }; }