Agent-bus band, /announce, commit and home-manager guards

Multi-host NixOS configuration using flakes, a single parameterised host template, Home Manager as a flake module, and Stylix-driven theming.
Last verified: 2026-09-01 against nixos-unstable (NixOS 26.11).
| Host | Class | Hardware | Role | Template |
|---|---|---|---|---|
| p620 | workstation | AMD RX 7900 (ROCm) | Primary development, AI workloads | desktop/workstation |
| p510 | workstation | Intel Xeon + RTX 3070 Ti | Media server (Plex), k3s, CI runner, desktop | desktop/workstation |
| razer | laptop | Intel + NVIDIA | Mobile development, Secure Boot | desktop/laptop |
All three run the same Wayland session — see Desktop below.
p510 is the always-on media server: never build or deploy it without asking first. Heavy razer rebuilds should be built on p620 (just deploy-via-p620 razer).
DEX5550 is offline; Samsung and HP are decommissioned. References in older docs are stale.
hosts/templates/desktop.nix (selects between workstation and laptop profiles via the profile argument). Surfaced through lib/hostTypes.nix as hostTypes.workstation / hostTypes.laptop. The previous server/hybrid/base templates were removed; p510 also uses the workstation template, overriding only what genuinely differs.hosts/common/hardware-profiles/ (amd, nvidia, intel-integrated).lib/features.nix).modules/ is imported explicitly by the template; there is no auto-discovery.overlays/: default.nix, custom-packages.nix, cmake-compat.nix, python-compat.nix, upstream-fixes.nix, citrix-workspace.nix.base16 palette), whose source of truth is the active Omarchy theme — see Theming. Dependent surfaces — Plymouth, GRUB, the console, GTK, Qt, GNOME Terminal — derive their colours from config.lib.stylix.colors. The standalone nix-colors input has been removed.flake.nix. Do not run home-manager switch directly; user environments are activated by the system rebuild.One Wayland session on every host: Omarchy on Hyprland, packaged for NixOS as Nixarchy and consumed as a flake input. GNOME stays installed and selectable as a fallback. Everything else — niri, labwc, mango, Noctalia, DankMaterialShell — is gone, including the flake inputs. COSMIC is parked, not removed.
| p620 | razer | p510 | |
|---|---|---|---|
| Sessions | omarchy, gnome | omarchy, gnome | omarchy, gnome |
| Greeter | Omarchy's SDDM | Omarchy's SDDM | Omarchy's SDDM |
| Default | omarchy | omarchy | omarchy |
| Autologin | off | off | on (Sunshine needs a live session) |
| Omarchy preinstalls | on | on | off (~4 GiB of closure) |
Wired per host in hosts/<host>/nixos/nixarchy.nix plus shared fragments under hosts/common/nixos/omarchy-*.nix.
Three things are easy to get wrong:
programs.hyprland is enabled by the Nixarchy module, not by us. It supplies the portal, uwsm and the wayland-session basics, so it cannot be turned off — but it also registers a second, indistinguishable Hyprland login entry. omarchy-sole-hyprland.nix forces the module off and restates what Nixarchy needs. Read that file before touching anything Hyprland-adjacent; the two obvious workarounds both fail.hyprctl dispatch 'hl.dsp.dpms({ action = "enable" })'. The string form ("dpms on") silently does nothing.nixarchy-apps.nix (by nixarchy-apply) and nixarchy-theme.nix (by omarchy theme set). Both leave the tree dirty, and nhs refuses a dirty tree.Full detail: docs/architecture/desktop.md.
git clone https://github.com/olafkfreund/nixos_config.git ~/.config/nixos
cd ~/.config/nixos
just validate # Configuration validation
just test-host p620 # Build a host without switching
just <host> # Build and switch (p620 / p510 / razer)
The recommended flow for the common case (bump lock, commit, push, build, switch) — works for local and remote hosts, refuses to run with a dirty tree, and never orphans a lock:
nhs # current host, nixpkgs scope (zsh shortcut)
nhs razer # remote deploy via SSH (nh --target-host)
nhs p510 all # update all flake inputs, deploy p510
nhs razer home-manager # bump a single input
# Without the alias:
just update-commit-deploy [HOST] [SCOPE]
# Pre-build for a currently offline host (commit + build now, deploy later):
nhsb razer
Full reference: docs/UPDATE-DEPLOY.md.
flake.nix Main flake (inputs, outputs, host wiring)
Justfile Automation recipes (just --list)
lib/ Shared functions (hostTypes, features, secrets)
modules/ Feature modules (explicit imports only)
overlays/ Split nixpkgs overlays
hosts/
templates/desktop.nix Single parameterised template (workstation | laptop)
common/ Shared host fragments + hardware-profiles
p620/ p510/ razer/ Per-host configurations
home/ Home Manager modules
profiles/ Role-based profiles (developer, server-admin, ...)
Users/<user>/<host>_home.nix Per-user, per-host Home Manager entry
secrets/ agenix-encrypted secrets (.age)
checks/ flake checks (nix flake check)
scripts/ Operational scripts
docs/ Documentation
assets/wallpapers/ Centralised wallpapers (consumed by Stylix)
# Validation
just check-syntax # Nix syntax pass
just validate-quick # Fast validation
just validate # Full validation
just test-host <host> # Build a host without switching
# Deployment
just deploy # Local switch
just <host> # Build + switch a specific host
just quick-deploy <host> # Deploy only if the closure changed
just update-commit-deploy <host> [scope] # See docs/UPDATE-DEPLOY.md
nhs [host] [scope] # zsh shortcut for the above
# Maintenance
just cleanup # GC old generations
just update-flake # nix flake update (no commit)
just secrets # Interactive secrets manager
just --list # All recipes
Hosts compose functionality through flags rather than direct service configuration. Example (from hosts/p620/configuration.nix):
features = {
development.enable = true;
desktop.enable = true;
virtualization = {
enable = true;
docker = true;
};
};
Adding a new service: create a module under modules/services/<name>.nix exposing features.<name> options, then import it explicitly from the template or host. Do not place services.* = { ... } blocks directly in hosts/*/configuration.nix.
Encrypted with agenix; decrypted at activation time only.
just secrets # Interactive manager
./scripts/manage-secrets.sh create <name>
./scripts/manage-secrets.sh edit <name>
./scripts/manage-secrets.sh rekey
Always reference secrets by path, never read them at evaluation time:
# Correct — runtime load
services.myapp.passwordFile = config.age.secrets.myapp-password.path;
# Wrong — embeds the secret in /nix/store
services.myapp.password = builtins.readFile "/secrets/password";
Access control lives in secrets.nix (per-host and per-user public keys).
razer boots via lanzaboote (v1.1.0) with systemd-initrd. Because the firmware ships a locked Setup Mode, MOK enrollment is handled by a shim+MOK module (modules/razer/...) that wraps lzbt and points pkiBundle at /var/lib/sbctl (the sbctl 0.18 default).
Kernel is linuxPackages_latest (7.0.1) because 6.18.24 + nvidia-open failed to boot. hosts/razer/nixos/boot.nix documents the fallback: pin 6.18.22 via a separate module if 7.0.1 also fails.
The ESP is 511 MiB against ~149 MiB of NVIDIA-firmware initrd per generation, so it holds very few generations. --clean escalates to a single generation rather than failing its own threshold.
omarchy theme set X retints Omarchy's own 24 files immediately and a theme-set.d hook writes X into nixarchy-theme.nix at the flake root; modules/desktop/stylix-theme.nix maps that theme's colors.toml onto base16 and feeds Stylix. Everything Omarchy cannot reach at runtime follows at the next rebuild, not instantly.inputs.nixarchy, never config.programs.nixarchy.package — nixarchy consumes Stylix, so that closes the module fixpoint and evaluation dies with infinite recursion.config.lib.stylix.colors (base16 scheme) drives Plymouth, GRUB, the console, GTK, Qt, fonts, cursor, icons and GNOME Terminal.assets/wallpapers/ contains all wallpapers; the active wallpaper is selected by the per-host theme module.desktop.cosmic.enable = false everywhere); the writer stays wired.home/profiles/desktop-user/profile.nix and the desktop.gnome.profile module unify the wiring; the desktop.displayManager module unifies display-manager selection.host.class (enum) is used to gate desktop-only Stylix targets so headless hosts don't pull GNOME assets.The following are intentionally absent from the current configuration:
journalctl, systemctl, and per-service logs. Older docs still list grafana-status / prometheus-status helpers; those commands no longer exist.dms-shell.nix, "Niri (DMS)", "Hyprland (DMS)" or ${DESK_SHELL:-noctalia} is describing a tree that no longer exists.p620:5000. modules/nix/nix.nix substitutes from cache.nixos.org and nix-community.cachix.org; flake.nix adds cuda-maintainers and devenv as flake-level extra-substituters. Tailscale is used for remote SSH, not caching.nix-colors input (replaced by Stylix base16).termshark, wireshark, reddix, wasistlos, steampipe modules and the standalone cosmic-applet-package-updater chain.vim from the base user package set (Home Manager vimAlias handles it).cosmic-ext-applet-radio as an upstream input — replaced by a local module workaround for an upstream mkPackageOption/description bug.gh issue develop <n> --checkout # Branch from issue
just test-host <host> # Build the change
just validate # Syntax + checks + flake check
git commit -m "type(scope): summary (#n)"
gh pr create --fill # Open PR
nhs <host> # After merge: lock-aware deploy
Four packages are tracked here rather than taken from nixpkgs, because we follow their upstreams faster than nixpkgs does. Every release used to mean a manual version + hash bump.
.github/workflows/package-autoupdate.yml does it nightly at 03:43 UTC: resolve upstream → rewrite version + hash → build → open PR → merge. The config is updated before you wake up. Deploying stays manual.
| Package | Upstream source | Update script |
|---|---|---|
claude-code | Anthropic GCS latest channel | update-claude-code-native.sh |
claude-desktop | Anthropic signed apt repo | update-claude-desktop.sh |
warp-terminal | app.warp.dev redirect chain | update-warp-terminal.sh |
antigravity | Hy4ri/antigravity-flake | update-antigravity.sh |
The logic lives in scripts/, not in YAML. Each script is idempotent, writes nothing when already current, and has a --check mode. The bump you get at 03:43 is one you can run and debug by hand:
./scripts/update-warp-terminal.sh --check # exit 1 if a bump is available
./scripts/update-antigravity.sh # apply it
The workflow then reduces to: run script → did git diff change anything → build → merge. No update logic is trapped in a YAML run: block where it can only be tested by pushing a commit.
Verification runs inline, then the same job merges. Pull requests opened with GITHUB_TOKEN do not trigger other workflows, so ci.yml never runs on them and gh pr merge --auto would wait forever on checks that never arrive. The job that merges is therefore the job that built it.
max-parallel: 1. Each matrix leg merges to main; running them concurrently just leaves the later ones rebasing against a moved base.
Most of these cost real debugging time. If you are building something similar, they are the reason this page exists.
magic-nix-cache is retired. It fails with Cache service responded with 400 and Our services aren't available right now. Replaced with cachix/cachix-action against our own cache.update-flake.yml had referenced secrets.CACHIX_AUTH_TOKEN since the day it was written, but the secret was never set — so the step was a silent no-op and every CI run rebuilt from source. cachix-action does not fail loudly on an empty token.GitHub Actions is not permitted to create or approve pull requests. It is a repository setting, not a workflow permission: gh api -X PUT repos/OWNER/REPO/actions/permissions/workflow \
-f default_workflow_permissions=write \
-F can_approve_pull_request_reviews=true
pkgs.antigravity-cli, which resolves to a flake input's package — ours is pkgs.customPkgs.antigravity-cli, a different derivation at a different version. The gate passed green without touching a single file the script had rewritten. A build gate pointed at the wrong attribute is worse than no gate, because it reads as proof.Packages file lists every historical release in arbitrary order. Reading it top-down returned a version 11,000 releases behind the one we had pinned — and it would have built fine, silently downgrading the package by more than a year. sort -V is load-bearing.sed will happily swap them, and the mistake only surfaces as a hash mismatch on the architecture you do not build.just check-syntax # Syntax errors
just diff <host> # Pending closure delta
nix flake check --show-trace # Detailed evaluation errors
journalctl -u <service> -f # Follow service logs
sudo nixos-rebuild switch --rollback # Roll back to previous generation
nhs / just update-commit-deploy reference (local + remote).See LICENSE.
hooks/register.tsx 74 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { bandLine, parseBus } from './bus'
5import { classify } from './classify'
6
7const BUS = 'agent-bus'
8const recent = atom({ plugin: 'fleet-guard', key: 'recent' } as const, [])
9const isHidden = atom({ plugin: 'fleet-guard', key: 'isHidden' } as const, false)
10
11const textOf = (r: { content: { text?: string }[] }) => r.content.map(c => c.text ?? '').join('\n')
12
13// `recent` reads the newest messages with no cursor, under the session's own
14// bus identity, so polling never consumes what read_new would show the model.
15async function poll($: EngineInterface) {
16 try {
17 const r = await $.mcp.call(BUS, 'recent', { limit: 3 })
18 if (!r.isError) await update($, recent, () => parseBus(textOf(r)))
19 } catch {
20 // bus unreachable: leave the band as it is
21 }
22}
23
24export const register: Register = on => {
25 let host = 'unknown'
26
27 on('session.start', async ($, e, next) => {
28 const system = await $.process.run(['readlink', '/run/current-system'])
29 host = /nixos-system-([^-]+)-/.exec(system.stdout)?.[1] ?? 'unknown'
30 await $.command.register({
31 name: 'announce',
32 description: 'Post to #agents as this host',
33 argumentHint: '<text>',
34 })
35 if (e.isInteractive) {
36 void poll($)
37 $.clock.every(60_000, () => poll($))
38 }
39 return next(e)
40 })
41
42 on('command.run', { command: 'announce' }, async ($, e) => {
43 // Only a person posts to the shared room: not another plugin, not the SDK.
44 if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')
45 return { text: '/announce only runs when you type it.' }
46 const text = e.args.trim()
47 if (!text) return { text: 'Usage: /announce <text>' }
48 const r = await $.mcp.call(BUS, 'post', { text: '[' + host + '] ' + text })
49 if (r.isError) return { text: 'Not posted: ' + textOf(r) }
50 void poll($)
51 return { text: 'Posted to #agents: ' + text }
52 })
53
54 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
55 const list = await read($, recent)
56 if (e.props.hasSurvey || list.length === 0 || (await read($, isHidden))) return next(e)
57
58 const { Box, Button, Text } = $.ui.resolve(e)
59 return (
60 <Box flexDirection="column">
61 {list.map(m => (
62 <Text dimColor>{bandLine(m, e.props.bodyColumns)}</Text>
63 ))}
64 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
65 </Box>
66 )
67 })
68
69 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
70 const verdict = classify(e.command)
71 return verdict.kind === 'deny' ? { deny: 'fleet-guard: ' + verdict.reason } : next(e)
72 }).catch(($, e, next) => next(e))
73}
74hooks/bus.ts 25 lines1import type { BusLine } from '../types'
2
3// Take the first array found at the top level (or the value itself) holding items with text;
4// items without a string `text` are dropped one by one, never the whole batch.
5export function parseBus(text: string): BusLine[] {
6 let json: unknown
7 try {
8 json = JSON.parse(text)
9 } catch {
10 return []
11 }
12 const candidates: unknown[] = Array.isArray(json) ? [json] : json && typeof json === 'object' ? Object.values(json) : []
13 const hasText = (i: unknown): i is Record<string, unknown> =>
14 !!i && typeof i === 'object' && typeof (i as { text?: unknown }).text === 'string'
15 const list = candidates.find((v): v is unknown[] => Array.isArray(v) && v.some(hasText)) ?? []
16 return list.filter(hasText).map(i => ({ at: typeof i.at === 'number' ? i.at : 0, text: i.text as string }))
17}
18
19export function bandLine(m: BusLine, columns: number): string {
20 const d = new Date(m.at)
21 const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
22 const line = `${hhmm} ${m.text.split('\n')[0]}`
23 return line.length > columns ? line.slice(0, Math.max(0, columns - 1)) + '…' : line
24}
25hooks/classify.ts 22 lines1// Visibility only: deploy, p510 and bus rules stay with the managed settings hooks
2// in modules/programs/claude-code-managed.nix, which run before any mod.
3export type Verdict = { kind: 'pass' } | { kind: 'deny'; reason: string }
4
5// The double-quoted message of `git commit -m/-am/--message`.
6const COMMIT_MSG = /git\s+(?:-C\s+\S+\s+)?commit\b[^|;]*?(?:\s-[a-z]*m|\s--message[=\s])\s*"([^"]*)/
7// home-manager in command position (after a separator, not inside prose).
8const HM_SWITCH = /(?:^|[;&|(\n])\s*(?:sudo\s+)?home-manager\b[^;&|\n]*\sswitch\b/
9
10export function classify(cmd: string): Verdict {
11 const msg = COMMIT_MSG.exec(cmd)?.[1]
12 // A quoted heredoc ($(cat <<'EOF' ...)) expands nothing, so it is safe.
13 if (msg !== undefined && !/^\$\(cat\s+<<-?\s*'/.test(msg) && /`|\$\(/.test(msg))
14 return {
15 kind: 'deny',
16 reason: "backticks or $( inside a double-quoted commit message run as commands; use git commit -F - <<'MSG'",
17 }
18 if (HM_SWITCH.test(cmd))
19 return { kind: 'deny', reason: 'Home Manager is a flake module here; never run home-manager switch' }
20 return { kind: 'pass' }
21}
22types/index.d.ts 8 lines1export type BusLine = { at: number; text: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'fleet-guard': { recent: BusLine[]; isHidden: boolean }
6 }
7}
8