# Declarative OpenCode + oh-my-openagent (oh-my-opencode) plugin setup. # # Mirrors ~/.config/opencode/ on the current workstation. # Imported by home/server.nix (sapphira). Auto-enables programs.opencode. # # Three files this module owns on disk (via xdg.configFile): # ~/.config/opencode/opencode.json <- programs.opencode.settings # ~/.config/opencode/tui.json <- programs.opencode.tui # ~/.config/opencode/oh-my-openagent.json <- oh-my-openagent plugin config # # Override any field in the importing module if needed. { config, lib, pkgs, ... }: let # Body of ~/.config/opencode/oh-my-openagent.json. # Loaded by the oh-my-openagent opencode plugin on startup. ohMyOpenagentConfig = { "$schema" = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json"; agents = { sisyphus = { model = "opencode/claude-opus-5"; variant = "max"; fallback_models = [ { model = "opencode/kimi-k3"; } { model = "opencode/gpt-5.6-sol"; variant = "medium"; } { model = "opencode/glm-5"; } { model = "opencode/big-pickle"; } ]; }; hephaestus = { model = "opencode/gpt-5.6-sol"; variant = "medium"; }; oracle = { model = "opencode/gpt-5.6-sol"; variant = "xhigh"; fallback_models = [ { model = "opencode/gemini-3.1-pro"; variant = "high"; } { model = "opencode/claude-opus-5"; variant = "max"; } ]; }; librarian = { model = "minimax-coding-plan/MiniMax-M3"; }; explore = { model = "opencode/gpt-5-nano"; fallback_models = [ { model = "minimax-coding-plan/MiniMax-M3"; } ]; }; "multimodal-looker" = { model = "opencode/gpt-5.6-sol"; variant = "low"; fallback_models = [ { model = "opencode/gpt-5-nano"; } ]; }; prometheus = { model = "opencode/claude-fable-5"; variant = "high"; fallback_models = [ { model = "opencode/kimi-k3"; variant = "high"; } ]; }; metis = { model = "opencode/claude-opus-5"; variant = "high"; fallback_models = [ { model = "opencode/kimi-k3"; variant = "low"; } ]; }; momus = { model = "opencode/gpt-5.6-sol"; variant = "xhigh"; fallback_models = [ { model = "opencode/claude-opus-5"; variant = "max"; } { model = "opencode/gemini-3.1-pro"; variant = "high"; } ]; }; atlas = { model = "opencode/claude-sonnet-4-6"; fallback_models = [ { model = "opencode/gpt-5.6-sol"; variant = "medium"; } { model = "minimax-coding-plan/MiniMax-M3"; } ]; }; "sisyphus-junior" = { model = "opencode/claude-sonnet-4-6"; fallback_models = [ { model = "opencode/gpt-5.6-sol"; variant = "medium"; } { model = "minimax-coding-plan/MiniMax-M3"; } { model = "opencode/big-pickle"; } ]; }; }; categories = { "visual-engineering" = { model = "opencode/gemini-3.1-pro"; variant = "high"; fallback_models = [ { model = "opencode/glm-5"; } { model = "opencode/claude-opus-5"; variant = "max"; } ]; }; ultrabrain = { model = "opencode/gpt-5.6-sol"; variant = "xhigh"; fallback_models = [ { model = "opencode/gemini-3.1-pro"; variant = "high"; } { model = "opencode/claude-opus-5"; variant = "max"; } ]; }; deep = { model = "opencode/gpt-5.6-sol"; variant = "medium"; fallback_models = [ { model = "opencode/claude-opus-5"; variant = "max"; } { model = "opencode/gemini-3.1-pro"; variant = "high"; } ]; }; artistry = { model = "opencode/gemini-3.1-pro"; variant = "high"; fallback_models = [ { model = "opencode/claude-opus-5"; variant = "max"; } { model = "opencode/gpt-5.6-sol"; variant = "high"; } ]; }; quick = { model = "opencode/gpt-5.4-mini"; fallback_models = [ { model = "opencode/gemini-3-flash"; } { model = "minimax-coding-plan/MiniMax-M3"; } { model = "opencode/gpt-5-nano"; } ]; }; "unspecified-low" = { model = "opencode/claude-sonnet-4-6"; fallback_models = [ { model = "opencode/gpt-5.6-sol"; variant = "medium"; } { model = "opencode/gemini-3-flash"; } { model = "minimax-coding-plan/MiniMax-M3"; } ]; }; "unspecified-high" = { model = "opencode/claude-sonnet-4-6"; fallback_models = [ { model = "opencode/gpt-5.6-sol"; variant = "medium"; } { model = "opencode/gemini-3-flash"; } { model = "minimax-coding-plan/MiniMax-M3"; } ]; }; writing = { model = "opencode/gemini-3-flash"; fallback_models = [ { model = "opencode/claude-sonnet-4-6"; } { model = "minimax-coding-plan/MiniMax-M3"; } ]; }; }; }; in let # nixpkgs ast-grep only ships binary `ast-grep`; omo's ast-grep skill probes # for `sg` (or ast-grep). Provide both via a symlink wrapper. astGrepWithSg = pkgs.runCommandLocal "ast-grep-with-sg" { } '' mkdir -p $out/bin ln -s ${pkgs.ast-grep}/bin/ast-grep $out/bin/ast-grep ln -s ${pkgs.ast-grep}/bin/ast-grep $out/bin/sg ''; in { programs.opencode = { enable = true; # Extras available to opencode-wrapped (via --suffix PATH on the wrapper): # pkgs.nodejs_22 — npx/npm for MCP servers (webpage-mcp) and omo's plugin loader # pkgs.ast-grep — `sg` CLI; omo's ast-grep skill requires it (omo doctor) # pkgs.bun — omo prefers bun; with bun on PATH, `omo doctor` skips node fallback # pkgs.gh — GitHub CLI; omo's GitHub automation features require it extraPackages = [ pkgs.nodejs_22 astGrepWithSg pkgs.bun pkgs.gh ]; # ~/.config/opencode/opencode.json settings = { plugin = [ "oh-my-openagent@latest" ]; mcp = { webpage = { type = "local"; command = [ "npx" "-y" "-p" "webpage-mcp@latest" "webpage-mcp-stdio" ]; }; }; }; # ~/.config/opencode/tui.json # Mirrors workstation: oh-my-openagent also registered for the TUI. tui = { plugin = [ "oh-my-openagent@latest" ]; }; }; # ~/.config/opencode/oh-my-openagent.json — read by the plugin on startup. xdg.configFile."opencode/oh-my-openagent.json".text = builtins.toJSON ohMyOpenagentConfig; # Same extras on the user's PATH too, so `omo doctor` and standalone invocations # of `sg`, `gh`, `bun`, `npm`, `npx` work in the user's shell — not only inside # the opencode-wrapped binary. home.packages = [ pkgs.nodejs_22 astGrepWithSg pkgs.bun pkgs.gh ]; # Expose `opencode web` as a systemd user service. nginx on sapphira # proxies https://opencode.zeroq.su -> 127.0.0.1:4096. # # --hostname 0.0.0.0 binds the listener to every interface (matches the # "0.0.0.0" intent; nginx then reverse-proxies 127.0.0.1:4096 internally). # --cors https://opencode.zeroq.su lets the browser session reach the # server from that origin without CORS rejection. # # SECURITY: with no password, anyone reaching the upstream socket gets full # opencode. Bind 0.0.0.0 + listener == bridge == shell. The password is # supplied via sops-managed EnvironmentFile, declared in modules/users.nix # and decrypted to a path hardcoded here (home-manager modules cannot read # `config.sops.*` — sops-nix options are NixOS-only). programs.opencode.web = { enable = true; environmentFile = "${config.home.homeDirectory}/.config/opencode/server.env"; extraArgs = [ "--hostname" "0.0.0.0" "--cors" "https://opencode.zeroq.su" ]; }; # RAM constraints for the opencode-web user service. # # Sapphira has 5.6 GiB RAM with a ~1 GiB baseline (syncthing + immich + gitea # + x-ui + nextcloud php-fpm). When something else spikes (immich-ml jobs, # syncthing indexer, etc.) the system OOM killer activates and picks the # largest cgroup — opencode at ~260 MiB – 1.4 GiB peak was being chosen and # systemd then restarted it every few seconds (`RestartSec=5`), masking the # real cause as a "service crash". The 2026-10-04 incident was exactly this. # # Three knobs together make opencode stop being an OOM victim AND stop being # the source of an OOM: # # MemoryHigh soft pressure threshold: kernel reclaims aggressively # once the cgroup hits this. Process keeps running. # MemoryMax hard cap: cgroup-local OOM kills Node if exceeded. The # HOST survives — only this process dies, no restart storm. # OOMScoreAdjust negative bias for the system-wide OOM killer: opencode # is killed last, after syncthing/immich/etc. # OOMPolicy "continue" — systemd does NOT auto-restart on cgroup # OOM-kill. Without this, a spike triggers the same # restart-loop the host saw today. # # Sizes are derived from observed peak (1.4 GiB at 16:36, 1.1 GiB at 16:59). # MemoryHigh = 1G gives headroom for normal runs; MemoryMax = 2G caps # pathological growth. Tweak both together if a workload legitimately # needs more. # # Refs: # https://www.freedesktop.org/software/systemd/man/systemd.resource-control.html # https://www.freedesktop.org/software/systemd/man/systemd.exec.html#OOMScoreAdjust= systemd.user.services.opencode-web.serviceConfig = { MemoryHigh = "1G"; MemoryMax = "2G"; OOMScoreAdjust = -900; OOMPolicy = "continue"; }; # Workaround: home-manager activation updates the GC root `current-home` # only at the very end (line 358 of the generated activate script), AFTER all # `home.activation.*` dag entries have run. So we cannot read current-home # from a dag entry — it still points to the OLD generation at the time our # script executes. Instead, read `new-home`, which the activator writes # BEFORE any dag entry runs and which already points at the new generation. home.activation.relinkHomeManager = lib.hm.dag.entryAfter [] '' target="$HOME/.local/state/nix/profiles/home-manager-24-link" newGen="$(readlink -e "''${XDG_STATE_HOME:-$HOME/.local/state}/home-manager/gcroots/new-home" 2>/dev/null || true)" if [[ -n "$newGen" && "$(readlink -f "$target")" != "$newGen" ]]; then echo "home-manager: relinking $target -> $newGen" ln -sfn "$newGen" "$target" fi ''; }