CCE

Caging the beasts

Contents

I've been using some cheap code generation tools to help me make arcology2go. Tokenizing and transmitting parts of my personal knowledge to these fucking ghouls is sickening, and doing so without constraints horrifying. Folks are going out there giving a remote server the ability to execute code on their servers. Mine get a dedicated machine — Sunset Dept, a full persistent-boot QEMU guest on Last Bank whose only host touchpoints are the virtiofs shares and its port forwards.

the langlemangle VM runner: Sunset Dept

Converted 2026-10-28 from the MicroVM harness (the clown VM definition) to a dedicated plain-QEMU VM that owns its state. Decisions carried from the conversion plan:

The eval needs the secrets file to exist: nixos/sops-langlemangle.nix bakes ../secrets/langlemangle.yaml into the guest's sops activation script, so nix flake check and any last-bank rebuild fail with 'secrets/langlemangle.yaml' does not exist in Git repository until rrix mints it with sops (placeholder values are fine — see the bootstrap runbook step 3).

  • State model: persistent full boot disk (root+state, ~100G qcow2 on the Last Bank host under /var/lib/langlemangle), seeded once from the flake build; the guest manages itself via =nixos-rebuild switch --flake /home/rrix/nix#sunset-dept=. Reseeding (delete root.qcow2, restart the unit) is the reset lever.

  • Shares: /home/rrix/{Code,org,nix} over virtiofs — one vhost-user-fs device per share, each served by a socket-activated virtiofsd (systemd owns the listener; qemu's chardev connection starts the daemon; a guest disconnect tears the daemon down until the next connect). DAX cache window stays off.

  • Desktop: Xfce on X11 inside a tigervnc Xvnc :0 in the guest (matches Hermes' computer-use cua-driver AT-SPI/XTest capture path and the upstream Bot Screen pattern), exposed through qemu user networking as hostfwd 5901→5901 — bound to host 127.0.0.1, so reach it over an ssh tunnel from the tailnet.

  • Console: qemu -vnc on 127.0.0.1:0 (host port 5900). This deliberately differs from the plan note's 127.0.0.1:1: display :1 is host port 5901, which collides with the guest-desktop forward on 5901, and qemu dies at startup on EADDRINUSE.

  • Retired: microvm.host, the microvm.vms.clown eval, the NAT block, writableStoreOverlay, var.img + nix-store-overlay.img, all the microvm-harness virtiofs shares, the shutdown-hang /nix/store mount hack, and the port 11434 forward (guest routing no longer uses a local ollama).

nix:tangle ~/nix/nixos/langlemangle-microvm.nix
{ inputs, pkgs, lib, config, ... }:

let
  cfg = config.services.langlemangle-vm;

  qemu = pkgs.qemu_kvm;

  shares = [
    { tag = "code"; hostPath = "/home/rrix/Code"; }
    { tag = "org"; hostPath = "/home/rrix/org"; }
    { tag = "nix"; hostPath = "/home/rrix/nix"; }
  ];

  # One socket-activated virtiofsd per share: the systemd socket owns the
  # listener, virtiofsd takes the pre-listening fd (fd 3) when the VM's
  # chardev opens the first connection, and exits on guest disconnect.
  virtiofsdUnits = builtins.listToAttrs (map (share: {
    name = "virtiofsd-${share.tag}";
    value = {
      description = "virtiofsd share ${share.tag} (${share.hostPath}) for the langlemangle VM";
      wantedBy = [ "sockets.target" ];
      socketConfig = {
        ListenStream = "/run/langlemangle/${share.tag}.sock";
        RemoveOnStop = true;
        SocketMode = "0600";
      };
    };
  }) shares);

  virtiofsdServices = builtins.listToAttrs (map (share: {
    name = "virtiofsd-${share.tag}";
    value = {
      description = "virtiofsd share ${share.tag} (${share.hostPath}) for the langlemangle VM";
      serviceConfig = {
        ExecStart = "${lib.getExe pkgs.virtiofsd} --fd 3 --shared-dir ${share.hostPath} --cache auto";
        LimitNOFILE = 1048576;
        Restart = "on-failure";
        RestartSec = 1;
      };
    };
  }) shares);

  socketArgs = lib.concatMapStrings (share: " -chardev socket,id=fs${share.tag},path=/run/langlemangle/${share.tag}.sock -device vhost-user-fs-pci,chardev=fs${share.tag},tag=${share.tag}") shares;

  hostfwd = lib.concatMapStringsSep "" (
    { host, guest }: ",hostfwd=tcp:127.0.0.1:${toString host}-:${toString guest},hostfwd=tcp:100.123.74.28:${toString host}-:${toString guest}") [
      { host = 10022; guest = 22; }
      { host = 8080; guest = 8080; }
      { host = 9119; guest = 9119; }
      { host = 5901; guest = 5901; }
    ];

  # Seed the persistent disk once from the flake-built guest image, then
  # grow the virtual size to diskSize — the guest's growPartition +
  # autoResize expand the filesystem into it on first boot.
  seedScript = pkgs.writeShellScript "langlemangle-vm-seed" ''
    if [ ! -e /var/lib/langlemangle/root.qcow2 ]; then
      echo "warning: no seed script enabled now...!!!"
    fi
  '';
  #seedScript = pkgs.writeShellScript "langlemangle-vm-seed" ''
  #  cd /var/lib/langlemangle
  #  if [ ! -e root.qcow2 ]; then
  #    echo "warning: no seed script enabled now...
  #    install -m 0600 "${inputs.self.nixosConfigurations.sunset-dept.config.system.build.diskImage}" root.qcow2
  #    ${qemu}/bin/qemu-img resize -f qcow2 root.qcow2 ${cfg.diskSize}
  #  fi
  #'';
in {
  options.services.langlemangle-vm = {
    cores = lib.mkOption {
      type = lib.types.int;
      default = 8;
      description = "vCPUs for the langlemangle VM, passed through with -cpu host.";
    };
    memory = lib.mkOption {
      type = lib.types.int;
      default = 32768;
      description = "MiB of guest RAM. vhost-user's memfd backend is sized to match.";
    };
    diskSize = lib.mkOption {
      type = lib.types.str;
      default = "100G";
      description = "Virtual size the seeded root.qcow2 is resized to. The guest grows into it on first boot.";
    };
  };

  config = {
    systemd.sockets = virtiofsdUnits;
    systemd.services = virtiofsdServices // {
      "langlemangle-vm" = {
        description = "Sunset Dept — the langlemangler's dedicated QEMU VM";
        wantedBy = [ "multi-user.target" ];
        after = [ "network.target" ];
        serviceConfig = {
          StateDirectory = "langlemangle";
          ExecStartPre = "${seedScript}";
          ExecStart = lib.concatStringsSep " " [
            "${qemu}/bin/qemu-system-x86_64"
            "-enable-kvm"
            "-cpu"
            "host"
            "-smp"
            (toString cfg.cores)
            "-m"
            (toString cfg.memory)
            # vhost-user-fs requires a shared memory backend; the size
            # matches -m exactly.
            "-object"
            "memory-backend-memfd,id=mem,size=${toString cfg.memory}M,share=on"
            "-numa"
            "node,memdev=mem"
            # Console VNC on host port 5900 (display :0). :1 is taken by
            # the guest-desktop forward; see the prose above.
            "-vnc"
            "127.0.0.1:0"
            "-netdev"
            "user,id=vmnic${hostfwd}"
            "-device"
            "virtio-net-pci,netdev=vmnic,mac=02:00:00:02:09:01"
            "-drive"
            "file=/var/lib/langlemangle/root.qcow2,format=qcow2,if=virtio"
            socketArgs
            "-serial"
            "mon:stdio"
          ];
          Restart = "on-failure";
          RestartSec = 5;
        };
      };
    };
  };
}

the sunset-dept guest package set

mkHost evaluates Sunset Dept against the 26.05 pin by default; the guest runs nixpkgs-unstable deliberately — hermes needs SQLite 3.51.3+ (WAL-reset corruption fix), unstable ships 3.53.3, and the rixpkgs overlay supplies the no-AVX2 bun and the opencode/pi-web overrides. The Sunset Dept snippet hands mkHost the unstable flake via its flake parameter — module set, lib and pkgs all in lockstep off the same pin — and this file contributes the guest's overlay on top (a 26.05 module set with a substituted unstable nixpkgs.pkgs is not sound: the eval-time externally-created-instance assertion and boot-time skews like boot.kernelFile both fire).

Lockstep also holds for home-manager: the shared SOPS-era HM input is the 26.05 release, which warns on the guest ("Home Manager version 26.05 and Nixpkgs version 26.11"). The snippet below registers an HM-unstable input and the Sunset Dept snippet passes it to mkHost as homeManagerModule; the other hosts keep the 26.05 pairing.

nix:tangle ~/nix/nixos/langlemangle-vm-pkgs.nix
{ inputs }:

import ../overlay.nix { inherit inputs; }
nix:tangle ~/nix/snippets/home-manager-unstable-input.nix
home-manager-unstable = {
  url = "github:nix-community/home-manager/master";
  inputs.nixpkgs.follows = "nixpkgs-unstable";
};

Hermes Agent

Hermes Agent runs as a home-manager user service under rrix (see the services.hermes-agent block in the Sunset Dept agent config below). The flake input provides the module. Why HM and not the NixOS module: the agent and its tools read the virtiofs shares (~/org, ~/Code, ~/nix) which are owned by rrix, and every other service in this VM (opencode-web, the desktop, chromium-puppet) is already a user service. HERMES_HOME lives at /home/rrix/hermes on the guest's own persistent boot disk — no share redirection, no claude-microvm indirection anymore. users.users.rrix.linger is set so the gateway survives logout.

Managed-mode caveats apply: hermes setup / hermes config set are blocked; the config comes from settings in the Nix block, secrets come from the sops-rendered /run/secrets/hermes-env (see the bootstrap runbook). Model routing: the default agent and the local alias ride Strata on the Wobserver (see Inference Server) on qwen3.8-flash-next-unsloth-ud-q4_k_xl at http://tomorrow-untrodden:8081/v1, keyed by STRATA_API_KEY from hermes-env; the llama.cpp router is the decision lane only since the 2026-11-04 split (=kev4b=/=d1-3b= for /v1/systemone, llamacpp provider kept keyless). Vision rides Strata too (=VISION=on= in the container env). The cloud alias lane is direct to Ollama Cloud (custom:ollamacloud on https://api.ollama.com) with glm-5.3:cloud — no more local ollama bridge in this VM; fallback_providers stays deliberately OFF (rrix, 2026-11-04).

Memory uses the holographic provider: local SQLite with FTS5, HRR compositional queries, trust scoring — no external dependencies, and the DB lands under HERMES_HOME next to the rest of the Hermes state. When hermes moved the provider out of core (it is a plugin now), the runtime catalog install could not finish on a read-only nix store — the PM publish step reads the packaged uv.lock next to the code — so the provider is declared as an HM home.file symlink into plugins/holographic at the catalog pin (0.17.4 @ 827c639e); see the module block.

The desktop app is built from the same flake: at the locked pin nix/packages.nix ships packages.desktop full.hermesDesktop= (all extraDependencyGroups, matrix included on Linux), and nix/desktop.nix wraps the nixpkgs Electron build with HERMES_DESKTOP_HERMES pinned to the flake's fully-wrapped hermes. The HM module exposes programs.hermes-agent.desktop.enable (confirmed at this pin, desktop/package option alongside); secrets for the desktop go through its extraRun wrapper hook — the desktop config sets services.hermes-agent.backend.sessionTokenFile and the module emits a --run line that reads the token from the sops-rendered path at each launch. extraEnv lines become --set values baked into the world-readable nix store, so no secret ever goes there.

Hermes Agent flake input

Hermes Agent ships its own flake with NixOS and Home Manager modules built via uv2nix. Only this VM consumes it so far, so it gets its own input snippet rather than piggybacking on the shared microvm input. Its input nixpkgs follows nixpkgs-unstable rather than the 26.05 pin: hermes-state needs SQLite 3.51.3+ (WAL-reset corruption fix; 26.05's 3.51.2 lacks it so every DB silently falls back to =journal_mode=DELETE=), and unstable ships 3.53.3. This closes the NEXT upgrade task.

nix:tangle ~/nix/snippets/hermes-agent-input.nix
hermes-agent = {
  url = "github:NousResearch/hermes-agent";
  # nixpkgs-unstable, not the 26.05 pin: hermes-state depends on SQLite
  # 3.51.3+ (or backports 3.50.7/3.44.6) for the WAL-reset corruption fix;
  # 26.05's python3.14 links 3.51.2 which lacks it, so every DB falls back
  # to journal_mode=DELETE. Unstable ships sqlite 3.53.3.
  inputs.nixpkgs.follows = "nixpkgs-unstable";
};

Sunset Dept agent config

The Sunset Dept guest spine + agent module, re-homed from the retired microvm-langlemangler.nix and microvm-hermes.nix blobs. It carries the guest-base settings (sshd, resolver, nix), the hermes HM module and settings, the opencode package + web server, the skills and MCP scripts, the himalaya mail account (FastMail; app password as a sops secret rendered to /run/secrets), and the HM module imports — because the VM now owns a persistent home on its own boot disk, all the claude-microvm redirection dies: no CLAUDE_CONFIG_DIR, no =XDG_DATA_HOME=/=XDG_CACHE_HOME= redirects, no OPENCODE_DB channel pin (opencode uses the default XDG paths; the bootstrap copies the old stable DB trio in as plain ~/.local/share/opencode/opencode.db).

With a persistent home, HM activation runs against real disk state — so home-manager.useGlobalPkgs is set and backupFileExtension is kept for the rare case a non-symlink target blocks a link.

nix:tangle ~/nix/nixos/langlemangle-vm-agent.nix
{ inputs, pkgs, lib, ... }:

{
  # ── Guest base ────────────────────────────────────────────────────────
  nix.settings = {
    extra-experimental-features = [ "flakes" "nix-command" ];
    substituters = [ "https://cache.nixos.org" "https://pebble.cachix.org" ];
    trusted-public-keys = [ "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" "pebble.cachix.org-1:RVkZU5Z0iWMvDk9SMIyPb5fpB5QRmt9OfySMo/cSO3M=" ];
  };

  # Host key mints on first boot at the standard path; sops-nix (imported
  # by the universal sops.nix module) reads it as the age identity.
  services.openssh.enable = true;

  networking.nameservers = [
    "100.100.100.100"
    "1.1.1.1"
  ];
  # Tailnet search domain so bare MagicDNS names (tomorrow-untrodden, …)
  # resolve from the guest over slirp NAT to the host's resolver.
  networking.search = [ "catla-catla.ts.net" ];
  networking.firewall.enable = false;

  programs.nix-ld.enable = true;

  environment.systemPackages = with pkgs; [
    bashInteractive
    htop
    vim-full

    nodejs
    poppler-utils
    gnumake
  ];

  # sops renders the multi-line env secrets to /run/secrets/<key>; the
  # units consuming them run as rrix, so rrix must read them. Until
  # secrets/langlemangle.yaml is encrypted to the guest's host key, these
  # files fail to render and the units bounce — the documented
  # chicken-and-egg, resolved by the bootstrap runbook's sops cycle.
  sops.secrets = {
    "hermes-env".owner = "rrix";
    "opencode-env".owner = "rrix";
    # FastMail app password (single-line value, minted with IMAP+SMTP
    # scopes under 2FA); himalaya reads it via auth.cmd below, so the
    # credential never lands in the world-readable store.
    "fastmail-app-password".owner = "rrix";
  };

  users.users.rrix.linger = true;

  home-manager.useGlobalPkgs = true;
  home-manager.backupFileExtension = "hm-bak";
  home-manager.extraSpecialArgs = { inherit inputs; };

  home-manager.users.rrix = {
    home.stateVersion = "25.05";
    imports = [
      inputs.hermes-agent.homeManagerModules.default
      ../hm/emacs.nix
      ../hm/direnv.nix
      ../hm/git.nix
      ../hm/profile.nix
      ../hm/prompt.nix
      ../hm/pi-web.nix
    ];
    programs.home-manager.enable = true;

    home.sessionVariables = {
      NIXPKGS_ACCEPT_ANDROID_SDK_LICENSE=1;
      NIXPKGS_ALLOW_UNFREE=1;
      # The claude-microvm XDG_DATA_HOME/XDG_CACHE_HOME/OPENCODE_DB
      # redirections are retired: opencode uses its default
      # ~/.local/share/opencode/ paths on the persistent disk.
      OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
      ANDROID_HOME = "${inputs.arcology2go.packages.${pkgs.stdenv.hostPlatform.system}.android-sdk}/libexec/android-sdk";
      GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${inputs.arcology2go.packages.${pkgs.stdenv.hostPlatform.system}.android-sdk}/libexec/android-sdk/build-tools/35.0.0/aapt2";
    };

    programs.opencode = {
      enable = true;
      package = pkgs.opencode;
      extraPackages = [ pkgs.direnv ];
      # Native OpenCode V2 config shape — migrated from the V1 shape
      # 2026-10-21 per https://opencode.ai/v2/docs/migrate-v1/. V2 was
      # already normalizing the old keys in memory (its /api/config view
      # of this file produced exactly this shape), so behavior is
      # unchanged; plugin → plugins, mcp.<server> → mcp.servers.<server>
      # (enabled → disabled), provider → providers with `npm` → `package`
      # (aisdk: prefix) and `options` → `settings`. The V1 `server` block
      # has no V2 config equivalent (serve flags carry hostname/port/cors,
      # see web.extraArgs below); the V1 models' `_launch` hints are
      # dropped — V2's normalization dropped them too.
      settings = {
        plugins = [
          "@simonwjackson/opencode-direnv"
        ];
        # Keep the V1 posture: V1 asked before touching anything outside
        # the project; V2 allows external dirs by default. Per-repo
        # opencode.json files narrow this with later-wins allow/deny rules.
        permissions = [
          { action = "external_directory"; resource = "*"; effect = "ask"; }
        ];
        mcp.servers.arcology-notes = {
          type = "local";
          command = [
            "uv" "run" "/home/rrix/.local/share/arcology-notes-mcp.py"
          ];
          environment = {
            ARCOLOGY_RISK_ENDPOINT = "http://tomorrow-untrodden:8080";
            ARCOLOGY_RISK_MODEL = "kev4b";
          };
          disabled = false;
        };
        mcp.servers.ollama-search = {
          type = "local";
          command = [
            "uv" "run" "/home/rrix/.local/share/ollama-web-search-mcp.py"
          ];
          environment = {
            OLLAMA_API_KEY = "{env:OLLAMA_API_KEY}";
          };
          disabled = false;
        };
        providers.ollama = {
          name = "Ollama";
          package = "aisdk:@ai-sdk/openai-compatible";
          settings.baseURL = "https://api.ollama.com/v1";
          models = {
            "glm-5.3:cloud" = { limit = { context = 999000; output = 131072; }; name = "glm-5.3:cloud"; };
            "glm-5.3-flash:cloud" = { limit = { context = 999000; output = 131072; }; name = "glm-5.3-flash:cloud"; };
            "glm-5.2:cloud" = { limit = { context = 999000; output = 131072; }; name = "glm-5.2:cloud"; };
            "deepseek-v4-pro:cloud" = { limit = { context = 1048576; output = 1048576; }; name = "deepseek-v4-pro:cloud"; };
            "deepseek-v4-flash:cloud" = { limit = { context = 1048576; output = 1048576; }; name = "deepseek-v4-flash:cloud"; };
            "kimi-k2.7-code:cloud" = { limit = { context = 262144; output = 262144; }; name = "kimi-k2.7-code:cloud"; };
            "kimi-k3:cloud" = { limit = { context = 262144; output = 262144; }; name = "kimi-k3:cloud"; };
            "qwen3-coder-next:cloud" = { limit = { context = 262144; output = 32768; }; name = "qwen3-coder-next:cloud"; };
            "qwen3.5:cloud" = { limit = { context = 262144; output = 32768; }; name = "qwen3.5:cloud"; };
            "gemma4:31b-cloud" = { limit = { context = 202752; output = 131072; }; name = "gemma4:31b-cloud"; };
          };
        };
      };
      web = {
        enable = true;
        # V2 serve: --cors accepts one origin per flag (repeatable). The
        # second --cors carries the origin that sat in the dropped V1
        # settings.server.cors block.
        extraArgs = [
          "--hostname" "0.0.0.0"
          "--port" "8080"
          "--cors" "http://virtuous-cassette:8080"
          "--cors" "http://last-bank:8080"
        ];
        # V2 serve is password protected: OPENCODE_PASSWORD must be known
        # to the server AND every client (run/api dispatches, browser
        # logins). Secrets can't live in a world-readable nix-store
        # writeText, and a bare path literal would get STORE-COPIED at
        # eval (leaking the secret + hard-requiring the file), so point
        # the unit at the sops-rendered multi-line secret — see the
        # bootstrap runbook's sops cycle.
        environmentFile = "/run/secrets/opencode-env";
      };
    };

    home.packages = [
      pkgs.tmux
      inputs.arcology2go.packages.${pkgs.stdenv.hostPlatform.system}.default
      inputs.arcology2go.packages.${pkgs.stdenv.hostPlatform.system}.android-env

      pkgs.claude-code
      pkgs.pi-coding-agent
      pkgs.pi-web

      pkgs.himalaya

      pkgs.uv
    ];

    # Ollama web search MCP server.
    # Upstream: https://github.com/ollama/ollama-python/blob/main/examples/web-search-mcp.py
    # Vendored and patched locally — see the "ollama web-search MCP server"
    # section below for the source and the reason it can't be fetched raw.
    # The arcology2go opencode.json references this path in its mcp.ollama-search block.
    home.file.".local/share/ollama-web-search-mcp.py".source = ../files/ollama-web-search-mcp.py;
    home.file.".local/share/arcology-notes-mcp.py".source = ../files/arcology-notes-mcp.py;

    # Himalaya mail account (FastMail). The config itself is public —
    # the only credential is the app password, which himalaya pulls at
    # auth time by `cat`ing the sops-rendered /run/secrets file (mode
    # 0400, owner rrix; see the sops.secrets block above).
    # Schema is the v2 flat layout (himalaya >= 2: `imap.server` URL +
    # `imap.sasl.plain.*`); the v1 [accounts.X.backend] table is
    # IGNORED by v2 with a "no supported backend" error. FastMail
    # endpoint facts: imaps://imap.fastmail.com:993 and
    # smtps://smtp.fastmail.com:465 implicit TLS; login is the plain
    # account address (aliases and plus-tags land in the one Inbox).
    # imap.id.auto = true is REQUIRED: FastMail refuses login without
    # the RFC 2971 ID exchange, which v2 otherwise leaves off by
    # default. No mailbox aliases needed — FastMail reports the
    # inbox/sent/drafts/trash roles natively.
    home.file.".config/himalaya/config.toml".text = ''
      [accounts.fastmail]
      default = true
      email = "rrix@fastmail.com"

      imap.server = "imaps://imap.fastmail.com:993"
      imap.sasl.plain.username = "rrix@fastmail.com"
      imap.sasl.plain.password.command = "cat /run/secrets/fastmail-app-password"
      imap.id.auto = true

      smtp.server = "smtps://smtp.fastmail.com:465"
      smtp.sasl.plain.username = "rrix@fastmail.com"
      smtp.sasl.plain.password.command = "cat /run/secrets/fastmail-app-password"
    '';

    services.hermes-agent = {
      enable = true;
      gateway.enable = true;
      backend.mode = "dashboard";
      backend.host = "0.0.0.0";
      backend.port = 9119;
      hermesHome = "/home/rrix/hermes";
      environmentFiles = [ "/run/secrets/hermes-env" ];
      # Matrix + E2EE: mautrix with encryption extras, built into the
      # sealed venv at build time. libolm comes from nixpkgs.
      extraDependencyGroups = [ "matrix" ];
      extraPackages = [
        pkgs.olm
        # arcology2go notes CLI — the arcology-notes skill and the
        # opencode.json permissions both rely on this being on PATH.
        inputs.arcology2go.packages.${pkgs.stdenv.hostPlatform.system}.default
      ];
      # The hermes-desktop Electron app attaches to this backend; the HM
      # desktop wiring reads the token from here at launch via the
      # wrapper's extraRun line (never extraEnv — that lands in the
      # world-readable store).
      backend.sessionTokenFile = "/run/secrets/hermes-desktop-token";

      settings = {
        providers.strata-docker = {
          api = "http://tomorrow-untrodden:8081/v1";
          transport = "chat_completions";
          key_env = "STRATA_API_KEY";
        };
        # llama.cpp router on the Wobserver (cce-wobserver/inference-server.org):
        # decision lane only since the 2026-11-04 split — /v1/systemone on
        # kev4b/d1-3b. Keyless — named custom providers without api/key_env
        # send "no-key-required".
        providers.llamacpp = {
          api = "http://tomorrow-untrodden:8080/v1";
          transport = "chat_completions";
        };
        # Ollama Cloud direct — the fallback lane and the `cloud` alias.
        # OLLAMA_API_KEY arrives via /run/secrets/hermes-env.
        providers.ollamacloud = {
          api = "https://api.ollama.com";
          key_env = "OLLAMA_API_KEY";
        };
        model = {
          provider = "custom:strata-docker";
          default = "qwen3.8-flash-next-unsloth-ud-q4_k_xl";
        };
        auxiliary = {
          # All auxiliary lanes ride Strata next to the default model.
          # VISION=on in the container env (the cce-wobserver/inference-server.org
          # strataSettings) is what makes the vision lane advertise image
          # input; the llama.cpp router has been decision-only since 2026-11-04.
          vision = { provider = "custom:strata-docker"; model = "qwen3.8-flash-next-unsloth-ud-q4_k_xl"; };
          compression = { provider = "custom:strata-docker"; model = "qwen3.8-flash-next-unsloth-ud-q4_k_xl"; reasoning_effort = "low"; };
          title_generation = { provider = "custom:strata-docker"; model = "qwen3.8-flash-next-unsloth-ud-q4_k_xl"; };
          mcp = { provider = "custom:strata-docker"; model = "qwen3.8-flash-next-unsloth-ud-q4_k_xl"; };
        };
        # Cloud fallback when the router is down or cold-loading. Kept OFF
        # deliberately (rrix, 2026-11-04) — do not enable as a cutover hedge;
        # the cold-start window is carried by patience, not by cloud escape
        # hatches.
        # fallback_providers = [
        #   { provider = "custom:ollamacloud"; model = "glm-5.3:cloud"; }
        # ];
        # Aliases mirror the router's behavior-tier section ids. An alias is
        # its own llama-server process — -deep is the xhigh "leave it running
        # overnight" tier and never co-resident with casual use.
        model_aliases = {
        #  local      = { model = "qwen3.8-flash-next";        provider = "custom:llamacpp"; };
        #  local-38   = { model = "qwen3.8-27b-q8";            provider = "custom:llamacpp"; };
        #  local-deep = { model = "qwen3.8-27b-q8-deep";      provider = "custom:llamacpp"; };
        #  local-code = { model = "qwen3.8-flash-next-coder"; provider = "custom:llamacpp"; };
          cloud      = { model = "glm-5.3:cloud";            provider = "custom:ollamacloud"; };
        };
        memory = {
          memory_enabled = true;
          user_profile_enabled = true;
          provider = "holographic";
        };
        plugins.hermes-memory-store = {
          db_path = "/home/rrix/hermes/memory_store.db";
          auto_extract = true;
        };
        # Risk-gate env for the arcology notes tools; the skill also
        # declares these in required_environment_variables.
        terminal.env_passthrough = [
          "ARCOLOGY_RISK_ENDPOINT"
          "ARCOLOGY_RISK_MODEL"
        ];
        matrix = {
          require_mention = true;
          session_scope = "room";
          auto_thread = true;
        };
        platforms.matrix = {
          enabled = true;
          home_channel = {
            platform = "matrix";
            chat_id = "!uedkSuJ5NcqV8a45OXet1nGOVxznfAVPD1ZTCF7GjNo";
            name = "DM with rrix";
          };
        };
      };

      # Skills live in HERMES_HOME/skills/. The arcology-notes skill
      # wraps the arcology2 notes CLI (env passthrough + fail-closed
      # risk gating documented in the skill body); the
      # langlemangle-opencode skill overrides the bundled opencode
      # skill's session advice so Hermes attaches to the running
      # opencode web server instead of spawning a second TUI.
      hermesHomeFiles = {
        "skills/arcology-notes/SKILL.md" = ../files/arcology-notes-skill.md;
        "skills/langlemangle-opencode/SKILL.md" = ../files/opencode-attach-skill.md;
      };

      mcpServers.ollama-search = {
        command = "uv";
        args = [
          "run"
          "/home/rrix/.local/share/ollama-web-search-mcp.py"
        ];
        env.OLLAMA_API_KEY = "\${OLLAMA_API_KEY}";
      };
    };

    # CLI + HERMES_HOME for interactive shells (sibling option to
    # services.hermes-agent, not nested in it).
    programs.hermes-agent.enable = true;

    # The holographic memory provider left core and lives as a catalog plugin
    # now; the runtime catalog install cannot finish on a read-only nix store
    # (the PM publish step reads the package tree's uv.lock), so it is
    # declared here instead: HM symlinks it into $HERMES_HOME/plugins/ as
    # "holographic" — the exact directory name the memory-provider discovery
    # (memory.provider) keys on. Pin: catalog 0.17.4 @ 827c639e.
    # snowballstemmer (its one hard dep) is already in the sealed venv;
    # numpy (embeddings / search by meaning) is guarded-optional in the
    # plugin and stays out until needed.
    home.file."hermes/plugins/holographic".source = pkgs.fetchFromGitHub {
      owner = "itpartypattaya";
      repo = "hermes-plugin-holographic";
      rev = "827c639e3cf38b17e9b5c1b6a4ef2ef7880ae953";
      hash = "sha256-XeT6wjwQ8IU3taTV6tsEPGpkmoAanVVkTtp/ub323A8=";
    };
  };
}

Sunset Dept desktop config

The guest's Bot Screen desktop, for Hermes' computer-use tooling and =cua-driver='s Linux capture path (AT-SPI on X11, XTest for input — the upstream pattern runs the Xfce session inside TigerVNC's Xvnc, which is the X server and the RFB server in one process, and cua-driver captures over AT-SPI from that same display server).

Xvnc is display :0: a user service under rrix, listening on guest port 5901, no RFB password — the runner's hostfwd binds it to host 127.0.0.1:5901 only, so the exposure is loopback-into-slirp and reach it via an ssh -L tunnel from the tailnet or last-bank itself. No local GPU X server and no display manager: the emulated video card only feeds qemu's console VNC (GRUB and repair). Everything user-level rides systemd user services started by the linger'd user manager, so the desktop is up before anyone logs in.

DISPLAY:0= goes into environment.d (home.sessionVariables) so every user service — chromium-puppet, hermes' cua-driver tooling — sees the display server without a login session.

programs.hermes-agent.desktop.enable ships the hermes-desktop Electron app (built from the flake's packages.desktop against nixpkgs Electron, node-pty compiled against electron.headers, HERMES_DESKTOP_HERMES pinned to the flake's wrapped binary); its secrets ride the wrapper's extraRun through the agent config's backend.sessionTokenFile. Electron/GL over Xvnc renders via llvmpipe — expected and fine for this hardware.

nix:tangle ~/nix/nixos/langlemangle-vm-desktop.nix
{ pkgs, lib, ... }:

{
  services.gnome.at-spi2-core.enable = true;

  sops.secrets."hermes-desktop-token".owner = "rrix";

  environment.systemPackages = [
    pkgs.emacs
    pkgs.vim
  ];

  home-manager.users.rrix = {
    home.sessionVariables.DISPLAY = ":0";

    home.packages = [
      pkgs.xfce.xfce4-session
      pkgs.xfce.xfwm4
      pkgs.xfce.xfdesktop
      pkgs.xfce.xfce4-panel
      pkgs.xorg.xrdb
    ];

    programs.hermes-agent.desktop.enable = true;

    systemd.user.services.xvnc-0 = {
      Unit = {
        Description = "Xvnc :0 — the Sunset Dept desktop display (RFB on guest 5901)";
        StartLimitIntervalSec = 300;
        StartLimitBurst = 5;
      };
      Install.WantedBy = [ "default.target" ];
      Service = {
        # Xvnc is a second binary, select it by name with getExe'.
        ExecStart = "${lib.getExe' pkgs.tigervnc "Xvnc"} :0 -rfbport 5901 -localhost no -SecurityTypes None -AlwaysShared -depth 24 -geometry 1920x1080 -desktop \"Sunset Dept\"";
        Restart = "on-failure";
        RestartSec = 5;
      };
    };

    systemd.user.services.xfce-session = {
      Unit = {
        Description = "Xfce session inside Xvnc :0";
        Requires = [ "xvnc-0.service" ];
        After = [ "xvnc-0.service" ];
        StartLimitIntervalSec = 300;
        StartLimitBurst = 5;
      };
      Install.WantedBy = [ "default.target" ];
      Service = {
        ExecStart = "${pkgs.dbus}/bin/dbus-run-session -- ${pkgs.xfce.xfce4-session}/bin/startxfce4";
        Environment = [
          "DISPLAY=:0"
          "XDG_CURRENT_DESKTOP=XFCE"
          "XDG_SESSION_TYPE=x11"
        ];
        Restart = "on-failure";
        RestartSec = 5;
      };
    };

    # Headed chromium for the browser toolset, driven over CDP.
    systemd.user.services.chromium-puppet = {
      Unit = {
        Description = "chromium with CDP on 9222 for the browser/computer-use toolset";
        Requires = [ "xfce-session.service" ];
        After = [ "xfce-session.service" ];
        StartLimitIntervalSec = 300;
        StartLimitBurst = 5;
      };
      Install.WantedBy = [ "default.target" ];
      Service = {
        ExecStart = "${pkgs.chromium}/bin/chromium --remote-debugging-port=9222 --remote-debugging-address=127.0.0.1 --user-data-dir=%h/.local/share/chromium-puppet --no-first-run --no-default-browser-check";
        Environment = [ "DISPLAY=:0" ];
        Restart = "on-failure";
        RestartSec = 5;
      };
    };

    systemd.user.tmpfiles.rules = [
      "e /home/rrix/.local/share/chromium-puppet/BrowserMetrics - - - 1d BrowserMetrics-*.pma"
    ];
  };
}

Matrix gateway

extraDependencyGroups [ "matrix" ]= pulls mautrix (with encryption extras, Linux-only) into the sealed venv at build time; pkgs.olm provides libolm. =MATRIX_E2EE_MODE=required= goes in the sops-rendered hermes-env secret so the gateway fails closed rather than silently downgrading to plaintext. Credentials (full setup docs) go in the same secret — homeserver URL, access token (or user id + password), MATRIX_ALLOWED_USERS and MATRIX_ALLOWED_ROOMS both set for a locked-down private deployment, plus the cross-signing MATRIX_RECOVERY_KEY. First E2EE start bootstraps cross-signing and writes the recovery key to =MATRIX_RECOVERY_KEY_OUTPUT_FILE=/home/rrix/hermes/matrix-recovery-key.txt= on the persistent disk (0600, never overwritten).

arcology-notes skill

Hermes' extension surface for wrapping an external CLI is a skill, not a config-declared tool: Creating Skills is explicit that capabilities expressible as instructions + shell commands should be skills, and there is no custom_tools block. The arcology2 notes verb set is exactly that — so this wraps it as a SKILL.md installed through hermesHomeFiles, not through another MCP server or JSON tool schema. (An earlier version of the arcology2go tools docs sketched a bogus {"name": ..., "command": [...]} JSON tool schema "for Hermes"; that mechanism does not exist and the docs now point here.)

The skill declares ARCOLOGY_RISK_ENDPOINT / ARCOLOGY_RISK_MODEL in required_environment_variables — on load Hermes registers them for terminal-sandbox passthrough — and settings.terminal.env_passthrough forwards them declaratively. The notes tools themselves fail closed when the risk classifier is unreachable.

markdown:tangle ~/nix/files/arcology-notes-skill.md:comments none
---
name: arcology-notes
description: Read, search, capture and UPDATE org-roam notes through the arcology2 notes CLI. ID-addressed, risk-gated, no raw file access.
version: 2.0.0
author: rrix
license: MIT
platforms: [linux]
metadata:
  hermes:
    tags: [Note-Taking, Org-Mode, Knowledge-Base]
    requires_toolsets: [terminal]
required_environment_variables:
  - name: ARCOLOGY_RISK_ENDPOINT
    prompt: Ollama endpoint (http://host:port) for the risk classifier
    help: Must serve the classifier model locally, e.g. http://windows:11434
    required_for: gate on note bodies before they leave the machine
  - name: ARCOLOGY_RISK_MODEL
    prompt: Classifier model name
    help: e.g. hermes3:8b
    required_for: risk classification model selection
---
Always edit the skill directly in
/home/rrix/org/cce-rrix/langlemangle_microvm.org and not using the
skill_manage skill.

# Arcology Notes

Read and write my org-roam notes via the `arcology2 notes` CLI. The
database lives at `~/org/arcology.db` (defaults are fine); tools are
flat CLI invocations and print a JSON envelope
(`{"ok": ..., "data": ..., "risk": ...}`) on stdout.

## When to Use

- Looking up things I wrote down: ideas, project notes, journal entries
- Searching notes before asking me a question about my work or plans
- Capturing something for later — a thought, a TODO, a journal entry
- Updating note state: toggling TODO keywords on a task by ID
- Any request about a TODO/habit's state ("mark it as done", "what's
  scheduled today"): find the task here — recurring habits surface via
  the Pitfalls sql join, flip state with `notes todo` — do NOT search
  the org tree on disk or hand-edit headings.

## Rules

- Access notes ONLY through this CLI. Never read files under `~/org`
  directly with read_file/terminal tools — and never WRITE them:
  `notes todo` / `notes capture` are the only sanctioned state
  changes, no matter how trivial the heading looks.
- Tool output is risk-gated PER ROW on timeline: only tripped rows come
  back `{redacted: true}` — clean rows stay visible. Same rule as ever:
  do not route around a redaction.
- IDs are opaque tokens (e.g. `20260929T120000.000001`) — use only what
  `notes search` / `notes timeline` / `notes get` gave you. ID-less
  timeline rows (`id: null`) are context only — they have NO body access
  by design.

## Quick Reference

Search returns ids/titles/outline only; fetch bodies with `notes get`
(positional ID, NEVER `--id`):

    arcology2 notes get "ID"

Fetch one note (metadata, children, links, refs, plaintext body).

Timeline v2 — ONE unified list of timestamped entries (planning stamps,
logbook state changes, inline active/inactive stamps, daily journal),
ordered by (date, epoch). Rows: {id|null, title|null, todo, kind, date,
epoch, olp, snippet}. ID-less rows carry olp+snippet with id:null:

    arcology2 notes timeline --from YYYY-MM-DD --to YYYY-MM-DD \
        [--kind scheduled,deadline,closed,active,inactive,logbook,journal]
        # --kind default: ALL kinds. CSV to narrow.
        # [--ensure-fresh] runs one incremental index pass before reading.
#
# An empty timeline window is NOT evidence that nothing is scheduled:
# before reporting "nothing scheduled/deadline in <window>", pull each
# journal note in the window with `notes get` and check the body for
# SCHEDULED:/DEADLINE: lines — bodies beat the index (see Pitfalls).

Capture appends to the daily journal and reindexes:

    arcology2 notes capture --title "Title" --body "text" \
        [--tags "a,b"] [--todo NEXT]
        # [--heading "ID"]  file as that node's LAST CHILD instead of journal
        # NOTE: there is NO by-outline-path variant — `--heading-by-olp`
        # documented here previously NEVER existed in llm/tools.org or the
        # tangled NoteTools.kt (git -S: zero commits). Verify flags against
        # llm/tools.org before trusting this doc.
        # [--gen-id]  generate an :ID: for the new entry — DEFAULT IS NO ID
        # capture with --heading returns {target_id, inserted} — no new id
          unless --gen-id was passed.
        # to correct faulty content in an already-captured journal entry,
          append a child: capture --heading "<entry node id>" titled
          "Correction to ..." — never rewrite the user's journal text.

Toggle TODO state by ID (writes a LOGBOOK state-change and applies the
REPEAT_TO_STATE reset; excluded ancestors return not_found). This and
`notes capture` are the ONLY sanctioned mutations of note/TODO state —
never raw-edit ~/org files, even habit headings that look trivial to
hand-edit (2026-10-11: a hand DONE+LOGBOOK edit to the SRS habit in
Habits.org had to be reverted and redone via the CLI):

    arcology2 notes todo "ID" --set NEXT    # or --clear
    # --todo-states "a,b,c" / $ARCOLOGY_TODO_STATES override the whitelist
    # (default: NEXT,INPROGRESS,DONE)
    # --set DONE on a recurring task DOES roll the SCHEDULED/DEADLINE
    # stamp (catch-up/cumulate semantics per the repo's org-mode rules),
    # resets the keyword per REPEAT_TO_STATE, and writes the LOGBOOK
    # entry — but the INSTALLED CLI can lag the repo: verify the actual
    # rollout with `notes get` (data.scheduled) against the expected
    # catch-up math and report that date, never assert from the envelope.
    # LAST_REPEAT is never written here (deferred feature) — a stale
    # LAST_REPEAT after DONE is normal, not a failure.
    # Never declare a stamp "unrolled" from a post-write read alone:
    # pin the pre-state first (prior LOGBOOK completions + the repeater
    # grid give it by arithmetic) or a short legitimate roll reads as
    # "no roll".

Refresh the index on demand (envelope:
{ok, data:{indexed, skipped, failed, removed}}; hash-skipped so it is
cheap even when nothing changed):

    arcology2 notes reindex [--db ~/org/arcology.db] [--org-dir ~/org]

Read-only SQL (single SELECT, row output risk-classified):

    arcology2 notes sql --query "SELECT id, title FROM nodes LIMIT 5"

## Procedure

1. Search first: `arcology2 notes search --query "..."`.
2. The envelope is a single JSON object on stdout — parse it before
   reading fields.
3. Fetch candidates with `notes get "ID"`; read `data.body`.
4. If `data.body.redacted == true`, the classifier withheld the content —
   report that instead of retrying.
5. To save something: `notes capture --title ... --body ...`; echo the
   returned id if present (`data.node_id` for journal captures) so I can
   reference it later.
6. If a read looks stale (a note I just made doesn't appear), re-run the
   read with `--ensure-fresh` or run `notes reindex` first.
7. If a timeline query returns nothing for a window where schedules
   should exist, diagnose IN THIS ORDER — never conclude "nothing
   scheduled" from the empty result:
   a. `notes get` the window's journal notes; read bodies for
      SCHEDULED:/DEADLINE: lines — bodies beat the index.
   b. `sql --query "SELECT file, COUNT(*) FROM tool_timestamps GROUP
      BY file"` — check whether the file has stamp rows at all. A
      file with zero rows was last indexed by an older parser, and
      hash-skip means neither `reindex` nor `--ensure-fresh`
      re-touches unchanged content.
   c. After `notes reindex`, re-run the per-file sql — the reindex
      envelope only counts changed files; it does not confirm YOUR
      file regained rows.
   d. Stamps still missing → check placement against Pitfalls and
      flag it to the user rather than asserting emptiness.

## Pitfalls

- org-mode tags can NOT contain dashes: use underscores if a tag is
  needed (`weekly_review` not `weekly-review`), or prefer an
  `[[id:...][Name]]` link in the title/body over tagging when the note
  belongs to a series (e.g. Weekly Review entries).
- An excluded note (`not_found` on an ID you saw before) is
  intentionally hidden — treat its existence as unknown.
- `--verbose` attaches a classifier transcript to the `risk` block;
  you do not get to call that yourself but may ask the user to do so.
- The CLI gates output when the classifier endpoint is UNREACHABLE
  (fails closed). `hermes3:8b` must be reachable at
  `$ARCOLOGY_RISK_ENDPOINT`.
- A classifier `http 400` is content-dependent, not an outage:
  `risk.available: false, reason: "classifier http 400"` with rows
  wrapped `{redacted: true}` can hit content-heavy row selects while
  `COUNT(*)`, schema and title-only queries in the same window still
  classify fine. Retry the gated query once; if it persists, stop —
  reshape the diagnosis toward content-independent queries (counts,
  sqlite_master, title-only) or report the gate. Never edit around a
  gated row.
- Journal entries captured WITHOUT an ID (the default) can only be
  found again via timeline rows / search — pass `--gen-id` when I ask
  for something addressable later.
- Captures filed as work/dev tasks (`--todo NEXT`, task-style tags)
  MUST pass `--gen-id`: todo/get toggle by ID only, and the CLI cannot
  retro-add an :ID: to a captured entry (verified 2026-10-09 when
  filing the repeater-roll bug).
- Org bodies are Org, not markdown: build capture --body once in a
  shell $'' literal with REAL newlines (never "\n" escapes — they land
  in the file verbatim). Inline code is ~=verbatim=~ or ~~~code~~~ and
  links are [[url][desc]] org brackets, never markdown backticks.
  Verified 2026-10-20: markdown-style first draft of a task body took
  capture + TODO-flip + a Correction child to fix.
- Planning stamps index ONLY when the SCHEDULED/DEADLINE line sits
  directly under the heading and ABOVE the :PROPERTIES: drawer (org
  owns "planning position"); a stamp written after the drawer never
  enters tool_timestamps. There is no --scheduled flag: typing a
  SCHEDULED: line into capture --body lands after the drawer and will
  not index — let the user stamp planning lines himself.
- tool_timestamps is under-reported for ANY file — hash-skip keeps
  stale/absent stamp rows even for a file the CLI reindexed earlier
  the same day (Habits.org, 2026-10-09). Trust note BODIES (notes
  get) for what was actually written; treat absence in
  tool_timestamps as "unverified", never as "not written".
- Never declare a stamp "did not change" from a post-state snapshot
  alone: establish the pre-state first (LOGBOOK history arithmetic,
  backups) or label the claim unverified (2026-10-09: a 09-04→10-09
  roll I misread as "unchanged" sat in the LOGBOOK history the
  whole time).
- Recurring habit tasks (STYLE:habit, REPEAT_TO_STATE, .+1w/.+1d
  repeaters) don't reliably surface in `notes search`. Find them by
  sql join: SELECT th.title, th.node_id, ts.date, ts.repeater FROM
  task_headings th JOIN task_timestamps ts ON ts.file=th.file AND
  ts.position=th.position WHERE th.title LIKE '...' — note
  task_timestamps keys on `position` while tool_timestamps uses `pos`.
- Reindexing habit files emits "Weekday mismatch" warnings from
  existing entries — parser noise, cosmetic, not a data problem.

## Verification

    arcology2 notes search --query "arcology" --limit 3

should emit `{"ok": true, ...}` with results.

langlemangle opencode attach skill

The VM already runs a persistent opencode web/API server on port 8080 (opencode web docs — V2 serves the web UI from the same server as the TUI, via opencode serve, programs.opencode.web in the VM config). The bundled opencode skill's interactive-session advice (`terminal(command="opencode", background=true, pty=true)`) would spawn a second agent with separate sessions/state under the same config — wrong for this machine. This skill shadows the relevant procedure: attach to the running server instead.

markdown:tangle ~/nix/files/opencode-attach-skill.md:comments none
---
name: langlemangle-opencode
description: "Drive OpenCode on this VM by ATTACHING to the always-running OpenCode V2 API/web server on port 8080 — one session per task, whole lifecycle in that session (plan, rrix approval, idle check, build flip), stop only via the server interrupt API. opencode owns the working copy in this skills dir; the driving agent owns the durable org skill block. NEVER run opencode under /home/rrix/org."
version: 3.3.0
author: rrix
license: MIT
platforms: [linux]
metadata:
  hermes:
    tags: [Coding-Agent, OpenCode, Autonomous]
    requires_toolsets: [terminal, process]
---

# OpenCode on the langlemangle VM

Verified on OpenCode V2, opencode 2.0.32; server API facts checked live
2026-10-21 against the V2 /api surface (migrated from the V1 1.18.33
facts of 2026-10-02: `--attach` → `--server`, `/session/*` → `/api/*`,
abort → interrupt). 3.3.0: the password env file moved from the
claude-microvm share to the sops-rendered `/run/secrets/opencode-env`.
Re-verify them after any opencode version bump.

Always edit the skill directly in
/home/rrix/org/cce-rrix/langlemangle_microvm.org and not using the
skill_manage skill.

## Overview

OpenCode here is NOT a plain CLI install: a persistent opencode
web/API server runs on `http://127.0.0.1:8080` (web UI
`http://last-bank:8080`; systemd user service). Its sessions, state
and provider config are canonical. Spawning a bare `opencode` TUI
would create a parallel session store — do not do that.

## V2 facts this install (verified 2026-10-21)

- The server is `opencode serve --hostname 0.0.0.0 --port 8080`
  (systemd user unit `opencode-web`, from the HM module) and serves
  BOTH the web UI and the V2 API under `/api`.
- It is password-protected. The server reads its password from
  `OPENCODE_PASSWORD` in its unit environment
  (`/run/secrets/opencode-env`, the sops-rendered secret); every client
  must export the same `OPENCODE_PASSWORD` before touching it. Raw
  `curl` to `/api/*` CANNOT authenticate (no header scheme) — drive the
  server through `opencode run --server` and
  `opencode api --server`, which handle it.
- `opencode run` has NO `--dir` and NO `--attach` in V2: `cd` into
  the repo first, pass `--server http://127.0.0.1:8080`; the
  project/location follows the client's cwd.
- Plain `opencode run` output does NOT print the session id in V2 —
  capture ids from `--format json` dispatches (JSONL; each line
  carries `sessionID`) or the list endpoint. Never guess or
  re-derive an id.

## Evolving this opencode skill

This skill exists in two places with a strict split of ownership:

- **Working copy — opencode owns it:** `SKILL.md` in this skills
  directory. Editing sessions run here with the client's cwd pointed
  at this directory; this file is what the build session actually
  writes.
- **Durable home — the DRIVING AGENT owns it:** the authoritative copy
  of this skill is the org src_block
  `langlemangle-opencode-attach-skill/1` in
  `/home/rrix/org/cce-rrix/langlemangle_microvm.org`, which
  `arcology2 tangle` regenerates and HM installs from. opencode NEVER
  touches that org file (see hard constraint below).

Edit cycle: user approves a plan → a build session rewrites SKILL.md
here → the driving agent (Hermes), never opencode, syncs the body into
the org src_block → tangle → HM install.

## One task = one session

A task = ONE server-side session id for its whole lifecycle: plan in
it, user approves the plan, idle check, build in the SAME session.
Follow-ups reuse `-s <session-id>`; a new `--title` only for a
genuinely new task. The only sanctioned second session is the
recovery path below.

### Long-brief dispatches: no scripts, one flat command

A build brief is a few short sentences in one positional arg — rrix
rejects shell-scripted dispatch wrappers (heredocs, shlex-assembled
scripts — "gnarly scripts", 2026-10-03). When the text is long: copy
the plan file INTO the repo (e.g. <repo>/scratch/plan.md, untracked
scratch the audit is allowed to see) and dispatch one flat command:
`cd <repo> && OPENCODE_PASSWORD=<pw> opencode run --server
http://127.0.0.1:8080 -s <id> --agent build -m
ollama/glm-5.3-flash:cloud --format json 'Build the approved plan
at <repo-relative-path> — read it in full first. <scope, limits,
tests, expected final message in a few short sentences>'`. Nothing
quoted except the single-quoted argv. Brief too long for one line?
Trim it and lean on the plan file — never write dispatch scripts to
/tmp.

### Lifecycle: canonical plan→build in ONE session

**Task status telemetry (org notes):** arcology2go tasks originate as
org-roam notes (id like `20261021T130453.000000`). When the task id is
known, flip its TODO state through the lifecycle with `arcology2 notes
todo "<id>" --set INPROGRESS` at dispatch time (plan or build). Get
the id from `notes search`/`notes get`, never guess it. This keeps
rrix's todo list and the rest of the agent system current with
dispatched opencode work. `--set DONE` is NEVER part of the agent's
lifecycle: rrix tests the built work himself. On verified completion
report that it is ready for his test and leave the state alone — he
marks DONE himself, or orders the flip.

1. **Start plan:**
   `cd <repo> && OPENCODE_PASSWORD=<pw> opencode run --server http://127.0.0.1:8080 --agent plan -m ollama/glm-5.3-flash:cloud --format json --title "<task title>" "<self-contained brief: repo, requirements verbatim, constraints, plan-only, deliverable format>"`
   — the JSONL stream carries `sessionID` on every line. Capture it
   once; it owns the task end to end. The brief must include the repo's
   permissions posture if its opencode.json restricts agents.
2. **Monitor:** background the run client, or poll the transcript with
   `OPENCODE_PASSWORD=<pw> opencode api --server http://127.0.0.1:8080
   get /api/session/<id>/message` (envelope `{data:[…], cursor}`; each
   entry is a message `{id, type, agent, model, time, content:[…]}` —
   assistant messages carry `content`, whose last `{type:"text"}` part
   is its latest report). Blocking idle-wait alternative:
   `... get /api/experimental/session/<id>/wait`. The run client
   STREAMS the agent's tool activity live; streamed tool output (help
   dumps, file reads) means the agent is working, not that something
   failed — do not misread it as an error and re-dispatch (this nearly
   caused a duplicate dispatch on 2026-10-02).
3. **Approval loop:** user reviews/tweaks the plan; feed their answers
   back with
   `cd <repo> && OPENCODE_PASSWORD=<pw> opencode run --server http://127.0.0.1:8080 -s <id> --agent plan -m ollama/glm-5.3-flash:cloud --format json "<answers>"`
   — still the plan agent, still the same session. One dispatch at a
   time; wait until the session is quiet between dispatches.
4. **Idle check (mandatory before the flip):** before dispatching into
   an existing session, verify it is not mid-turn: poll `time.updated`
   (from `... api get /api/session --param directory=<dir>`) twice
   about 10s apart;
   unchanged plus last message from the assistant = safe to proceed.
   Alternatively block on
   `... get /api/experimental/session/<id>/wait`.
   If it is still moving, `OPENCODE_PASSWORD=<pw> opencode api --server
   http://127.0.0.1:8080 post /api/session/<id>/interrupt`, re-verify it
   is quiet, then proceed. Never dispatch build while any agent may
   still be running.
5. **The flip:**
   `cd <repo> && OPENCODE_PASSWORD=<pw> opencode run --server http://127.0.0.1:8080 -s <id> --agent build -m ollama/glm-5.3-flash:cloud --format json "Plan approved — execute it. <approved decisions / scope notes>"`
   — build inherits the whole session context INCLUDING the full plan
   text the plan agent already produced: do NOT re-paste the plan and
   do NOT write it into a scratch file for the worker to re-read
   (observed 2026-10-07: build wasted a read re-ingesting a plan file;
   rrix — "the plan is in session. Just say the plan is approved and
   execute it"). Only inline plan text when the session genuinely
   lacks it (the recovery path below). Id-echo check: EVERY line's
   `sessionID` in this dispatch's JSON stream
   MUST equal `<id>`; mismatch = incident —
   stop, see step 7.
6. **Long builds:** same command with the run client backgrounded
   (`terminal background=true`); the session reports when done; poll
   the transcript. On verified completion, report the result to rrix
   for his testing and STOP — the DONE flip is his, never mine (see
   task status telemetry above).
7. **If the id-echo check fails, or evidence of a second agent
   appears:** immediately POST the interrupt endpoint on the NEW session
   id, then re-diagnose. The only clean re-entry is the recovery path
   below. NEVER pkill/timeout the client to "stop" anything (see
   Stopping things safely).

## Lifecycle: recovery path — fresh build session (verified clean 2026-10-02)

When the same-session flip goes wrong (flip into a session that was
not idle, duplicate agent, id mismatch):

1. Interrupt the misbehaving session:
   `OPENCODE_PASSWORD=<pw> opencode api --server http://127.0.0.1:8080 post /api/session/<bad-id>/interrupt`.
2. Dispatch a FRESH build session fed the plan text:
   `cd <same-dir> && OPENCODE_PASSWORD=<pw> opencode run --server http://127.0.0.1:8080 --agent build -m ollama/glm-5.3-flash:cloud --format json --title "<task title> (build)" "Build the approved plan. Full plan text: <verbatim>. Prior plan session: <plan-id>."`
3. The NEW id becomes the task's canonical session id and the
   One-Session Rule rebinds to it; never re-dispatch the old plan
   session.

## Session-id discipline

1. The id printed by the dispatch is the only authoritative id;
   capture it once, never guess or re-derive it.
2. Echo-check every dispatch: the printed id must equal the `-s` id
   you addressed; on mismatch abort the new id immediately, then
   re-diagnose.
3. One id owns the task; all follow-ups carry `-s <id>`; new `--title`
   only for a genuinely new task.
4. Serialize dispatches: never two concurrent agents against one tree;
   quiet-verified between each.
5. Discovery fallback: `... api get /api/session --param directory=<dir>`
   and pick newest by `time.created` — a last resort, not the primary
   way to obtain ids. (V2 session JSON carries `location`, not a plain
   `directory` field — filter server-side with the directory param.)
6. After any client kill, assume the server-side agent STILL lives;
   check the transcript before touching anything.

## Stopping things safely

- The agent runs SERVER-side. Killing the client (pkill, timeout,
  SIGTERM on the `opencode run` command) does NOT stop it — the run
  keeps executing and its output still re-enters as a
  background-process notification.
- The ONLY safe stop is the server API:
  `OPENCODE_PASSWORD=<pw> opencode api --server http://127.0.0.1:8080
  post /api/session/<id>/interrupt` — 2xx from the api client on
  success. Verify quietness afterward (no new messages).
- Cautionary tale: dispatching build while the plan agent could still
  have been mid-turn put two agents on one tree; edits interleaved
  invisibly, the sessions could not see each other and thrashed
  through a bunch of tokens and cleanup fell on user by hand. The idle
  check exists to prevent exactly this.

## Server API cheat sheet (opencode 2.0.32, V2 /api surface)

All API calls go through the api client, which handles the password:
prefix every call `OPENCODE_PASSWORD=<pw> opencode api --server
http://127.0.0.1:8080 <args>`. Raw curl cannot authenticate `/api/*`
(401, no header scheme) — verified 2026-10-21.

- Server up (unauthenticated OK): `curl -fsS http://127.0.0.1:8080/`
- List sessions, filtered by repo dir:
  `... get /api/session --param directory=<dir>`
  (the unfiltered list spans projects). Session JSON carries `id`,
  `agent`, `title`, `model`, `time.created`, `time.updated` (and
  `time.idle`; no plain `directory` field).
- Transcript:
  `... get /api/session/<id>/message` → envelope `{data:[…], cursor}`;
  the last assistant message's last `{type:"text"}` content part =
  its latest report.
- Idle block:
  `... get /api/experimental/session/<id>/wait`; active sessions:
  `... get /api/session/active`.
- The ONLY safe stop:
  `... post /api/session/<id>/interrupt` (V1's `/session/<id>/abort`
  endpoint is gone).

## Always pass a model

The server's default model is broken (`inherit/`): EVERY `opencode
run` MUST carry `-m ollama/glm-5.3-flash:cloud` (or another live
`ollama/*` model) or the dispatch dies with `Unexpected server error`.

## Non-interactive one-shots

Fine to skip the plan→build choreography for trivial reads:

    cd <repo>
    OPENCODE_PASSWORD=<pw> opencode run --server http://127.0.0.1:8080 \
      --agent build -m ollama/glm-5.3-flash:cloud 'one-shot task'

(No `--title` needed — the prompt truncates into the session title.)

## Per-repo permissions & the nixpkgs research recipe (verified 2026-10-05)

The nix-managed global config now asks before ANY external-directory
access (the `permissions` = `[external_directory *: ask]` rule — the
V1 default posture; V2 without it silently allows outside reads).
A two-line CCE change once cost ~60 tool calls that way, so repos
that get driven regularly carry their own `opencode.json` which
narrows those prompts with later-wins rules.
Fix pattern, live in `~/org/arroyo-system`
(`opencode.json` + `AGENTS.md`), V2 array form: `permissions` rules,
last-match-wins — `shell *: ask` FIRST, read-only allowlist
(grep/rg/cat/ls/`sed -n`/diff/man, `git status*`…,
`nix flake metadata/prefetch*`, `arcology2 *`) next, denial rules
(`sudo *`, `nixos-rebuild*`…) after that; `external_directory` rules
deny `~/org/**` then allow the CCE repos + `/nix/store/**` +
`~/nix/**`; `agents.plan.permissions` = `edit *: ask` (with a
`~/.opencode/plan/*` allow appended after it so V2's plan store keeps
working). Copy that `opencode.json` into any repo whose sessions need
the posture — repo config only loads when the client runs in that
repo (project rules are appended after the global ones, so they win).

Research recipe, NO un-tarring `/nix/store/*-source.tar*` (the tree is
already unpacked beside the tarball): read the rev from
`~/nix/flake.lock`, then `nix --extra-experimental-features 'nix-command
flakes' flake prefetch --json github:owner/repo/<rev>` and browse the
JSON's `storePath` (nixpkgs modules under `nixos/modules/`, home-manager
under `modules/`). `$NIX_PATH` usually already points at the pinned
nixpkgs tree. `nixos-option` resolves against the DEPLOYED config —
freeform `settings.<x>` leaves (e.g. sabnzbd `settings.misc.*`) error
there; read module source instead. `man configuration.nix` does not
exist on these machines.

## Pitfalls

- NEVER `terminal(command="opencode", ...)` bare — second session
  store. ALWAYS `--server http://127.0.0.1:8080` (V2: `--attach` does
  not exist).
- Running under `/home/rrix/org` floods permission dialogs UNLESS the
  target repo carries an `opencode.json` with an `external_directory`
  allowlist (arroyo-system does, verified prompt-free 2026-10-05;
  the nix global config asks about external dirs since the 2026-10-21
  V2 migration). Repos without one still flood — copy the config
  first, or stay out.
- NEVER `--auto` — it auto-approves permissions the user wants to be
  asked about.
- Two agents, one tree: dispatching build into a busy/unverified
  session interleaves edits invisibly; cleanup is manual and falls on
  the user. Do the idle check every time.
- Client death != agent death: killing the `opencode run` client
  leaves the server-side agent running; re-dispatching on top of it is
  how duplicate agents happen.
- Driver tooling: on this Hermes install `execute_code` prompts user
  for approval on every script — drive opencode with plain terminal
  calls instead (background the run client; poll/wait with the
  process tool). A blocked or timed-out dispatch created no session.
- Interactive clients (V2: `opencode --server http://127.0.0.1:8080`
  with `OPENCODE_PASSWORD` exported) need `pty=true` and lock the
  terminal while open; prefer `opencode run --server` for automation.
- The web server is shared — a human may be watching the same session
  from a browser. Don't rename/fork sessions unasked.
- Model/provider config comes from the nix-managed
  `~/.config/opencode/opencode.json`; do not edit it at runtime.
  Repo-level `opencode.json` (in-repo, tracked) scopes per-agent
  permissions — plan agents may be bash-restricted there.
- V2 `opencode run` has no `--dir` at all — the client's cwd selects
  the project/location. Always `cd <repo>` explicitly; never dispatch
  from an ambient cwd.

## Verification

    curl -fsS http://127.0.0.1:8080/ >/dev/null && echo server-up
    OPENCODE_PASSWORD=<pw> opencode api --server http://127.0.0.1:8080 \
      get /api/session --param directory=<dir>   # reachable, lists sessions
    cd <repo>
    OPENCODE_PASSWORD=<pw> opencode run --server http://127.0.0.1:8080 \
      -m ollama/glm-5.3-flash:cloud 'Respond with exactly: OPENCODE_SMOKE_OK'
    # expect: OPENCODE_SMOKE_OK (verified 2026-10-21)

Re-verify the API cheat sheet after any opencode version bump.

Bootstrap runbook

Converted 2026-10-28 — this replaces the old host-prerequisites provisioning (the env files are sops secrets now, and the VM owns its own persistent disk). One-time, from the host Last Bank and inside the guest:

  1. Build + start. Rebuild Last Bank after tangling — the rebuild also evaluates and builds =sunset-dept='s system.build.diskImage. Start the guest: sudo systemctl start langlemangle-vm.service. ExecStartPre seeds /var/lib/langlemangle/root.qcow2 from the built image and qemu-img-resizes it to diskSize; the guest's first boot grows the ext4 root (boot.growPartition + autoResize) and mints its own ssh host key at the standard path. Until the sops cycle in step 4 lands, the guest's hermes-agent and opencode-web user units bounce on the unreadable /run/secrets files — documented, harmless.

  2. Backfill the sops recipient. ssh in over localhost:10022 from Last Bank (or tunnel over the host's tailnet), then compute the age recipient from the freshly-minted host key: cat /etc/ssh/ssh_host_ed25519_key.pub | ssh-to-age. Move the placeholder line under the Sunset Dept heading in NixOS Host Definitions into the properties drawer as the real :ARROYO_AGE_RECIPIENT:, flood + retangle (.sops.yaml regenerates), then on a secrets-capable host: sops updatekeys secrets/langlemangle.yaml.

  3. Create the secrets file. sops secrets/langlemangle.yaml (encrypted to the personal identities + the new recipient) holding three values — two of them multi-line env-file strings:

    • hermes-env: OLLAMA_API_KEY (the existing value from the claude-microvm/ollama share — Ollama Cloud key for the ollamacloud fallback and the MCP env expansion), STRATA_API_KEY (must equal wobserver's strata-env secret in secrets/clanker.yaml — see cce-wobserver/inference-server.org; until it lands here it lives hand-merged in ~/hermes/.env), =ARCOLOGY_RISK_ENDPOINT=http://tomorrow-untrodden:8080=, =ARCOLOGY_RISK_MODEL=kev4b=, and the Matrix gateway variables (MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_PASSWORD, MATRIX_ALLOWED_USERS, MATRIX_ALLOWED_ROOMS, =MATRIX_E2EE_MODE=required=, =MATRIX_RECOVERY_KEY_OUTPUT_FILE=/home/rrix/hermes/matrix-recovery-key.txt=). HM activation merges it into HERMES_HOME/.env via hermes-env-merge; to apply without a VM rebuild, run the merge by hand, then restart the gateway user service.

    • opencode-env: OPENCODE_PASSWORD — carry the existing value from /home/rrix/claude-microvm/opencode-web.env on Last Bank; the web UI session (and every opencode run --server dispatch) is keyed to it, so don't rotate across the cutover — plus OLLAMA_API_KEY, also carried over.

    • hermes-desktop-token: the one genuinely new secret — generate it (openssl rand -hex 32), one line, raw token; read at each launch through the desktop wrapper's extraRun.

  4. Rebuild + verify secrets render. =sudo nixos-rebuild switch --flake ~/nix#last-bank= once more; the guest picks up the re-encrypted yaml at its next boot (or a guest-side nixos-rebuild switch), the units stop bouncing, and the gateway comes up.

  5. Copy old state into the guest (before hermes starts, with the old clown VM powered off):

#+begin_src sh :tangle no # on last-bank — hermes state to the guest's home via scp over 10022: rsync -a /home/rrix/claude-microvm/hermes/ \ rrix@localhost:/home/rrix/hermes/ rsync -a /home/rrix/claude-microvm/share/opencode/opencode-stable.db* \ rrix@localhost:/home/rrix/.local/share/opencode/ # then IN THE GUEST — the one-time import as the default V2 DB name: mv /home/rrix/.local/share/opencode/opencode-stable.db \ /home/rrix/.local/share/opencode/opencode.db # move the -wal / -shm companions along if present; backups land here too #+end_src

The OPENCODE_DB channel pin is retired: the default opencode.db name is canonical now, so no env var or runbook goes beyond the rename above (a .db copy alone is NOT a snapshot — the committed transactions sit in the -wal — hence the glob).

  1. Burn-in. The Xfce session, chromium-puppet and the desktop come up from the linger'd user manager; smoke-test from inside the guest: curl -fsS http://127.0.0.1:9222/json/version (chromium CDP), systemctl --user status xvnc-0 xfce-session hermes-agent opencode-web, hermes computer-use doctor; peek at the desktop over an ssh -L 5901 tunnel to localhost:5901.

  2. Org round-trip. From inside the VM: arcology2 notes capture / notes get against ~/org/arcology.db on the org share — with the risk gate on (no --no-risk-gate: the repos' opencode.json denies it). The shared arcology.db keeps the same journal_mode=DELETE posture the harness already ran; verify the round trip anyway.

opencode configuration parity

The HM-generated opencode.json gets the same ollama-search MCP (uv-run, same script — the Hermes mcpServers.ollama-search block above points at it too); the arcology-notes skill documents an intent to also carry a bash permission allow-list for arcology2 notes * mirroring the tools docs guidance — the harness gets notes through the narrow tool or not at all. (As of the 2026-10-21 V2 migration the global config carries only the external_directory ask rule; the notes allow-list is per-repo — set it where sessions need it.) Hermes reaches the same tools through the skill.

opencode with no-AVX2 bun

The langlemangle VM's vCPUs do not expose AVX2 (Last Bank's CPU passes through with -cpu host). nixpkgs' bun ships the stock bun-linux-x64.zip which is compiled assuming AVX2 and crashes with SIGILL (illegal instruction) on hosts like My Homeserver. Bun publishes a bun-linux-x64-baseline.zip for exactly this case, we just need to use it.

Below are two vendored copies of nixpkgs package definitions: a modified bun that pulls the baseline x86_64 binary, and an opencode that builds against it. They are wired in to the rixpkgs overlay.

the bun-baseline package

References

A modified copy of nixpkgs' pkgs/by-name/bu/bun/package.nix. The only change vs upstream: the x86_64-linux entry in passthru.sources points at bun-linux-x64-baseline.zip. Everything else is unchanged.

nix:tangle ~/nix/pkgs/bun-baseline.nix
{
  lib,
  stdenvNoCC,
  fetchurl,
  autoPatchelfHook,
  unzip,
  installShellFiles,
  makeWrapper,
  openssl,
  writeShellScript,
  curl,
  jq,
  common-updater-scripts,
  cctools,
  darwin,
  rcodesign,
}:

stdenvNoCC.mkDerivation (finalAttrs: {
  version = "1.3.13";
  pname = "bun";

  src =
    finalAttrs.passthru.sources.${stdenvNoCC.hostPlatform.system}
      or (throw "Unsupported system: ${stdenvNoCC.hostPlatform.system}");

  sourceRoot =
    {
      aarch64-darwin = "bun-darwin-aarch64";
    }
    .${stdenvNoCC.hostPlatform.system} or null;

  strictDeps = true;
  nativeBuildInputs = [
    unzip
    installShellFiles
    makeWrapper
  ]
  ++ lib.optionals stdenvNoCC.hostPlatform.isLinux [ autoPatchelfHook ];
  buildInputs = [ openssl ];

  dontConfigure = true;
  dontBuild = true;

  installPhase = ''
    runHook preInstall

    install -Dm 755 ./bun $out/bin/bun
    ln -s $out/bin/bun $out/bin/bunx

    runHook postInstall
  '';

  postPhases = [ "postPatchelf" ];
  postPatchelf =
    lib.optionalString stdenvNoCC.hostPlatform.isDarwin ''
      '${lib.getExe' cctools "${cctools.targetPrefix}install_name_tool"}' $out/bin/bun \
        -change /usr/lib/libicucore.A.dylib '${lib.getLib darwin.ICU}/lib/libicucore.A.dylib'
      '${lib.getExe rcodesign}' sign --code-signature-flags linker-signed $out/bin/bun
    ''
    + lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
      installShellCompletion --cmd bun \
        --bash <(SHELL="bash" $out/bin/bun completions) \
        --zsh <(SHELL="zsh" $out/bin/bun completions) \
        --fish <(SHELL="fish" $out/bin/bun completions)
    '';

  passthru = {
    sources = {
      "aarch64-darwin" = fetchurl {
        url = "https://github.com/oven-sh/bun/releases/download/bun-v${finalAttrs.version}/bun-darwin-aarch64.zip";
        hash = "sha256-VGfj9l26Umuf6pjwzOBO+vwMY+Fpcz7Ce4dqOtMtoZA=";
      };
      "aarch64-linux" = fetchurl {
        url = "https://github.com/oven-sh/bun/releases/download/bun-v${finalAttrs.version}/bun-linux-aarch64.zip";
        hash = "sha256-cLrkGzkIsKEg4eWMXIrzDnSvrjuNEbDT/djnh937SyI=";
      };
      # BASELINE: bun-linux-x64-baseline.zip — no AVX2 required.
      # Upstream (AVX2) hash for bun-linux-x64.zip is
      #   sha256-ecB3H6i5LDOq5B4VoODTB+qZ0OLwAxfHHGxTI3p44lo=
      # Baseline digest (from GitHub release SHASUMS) is
      #   9d8a24292a7068090205daac0a5a223f5f69736f5287e37bf88d3b4031edc750
      "x86_64-linux" = fetchurl {
        url = "https://github.com/oven-sh/bun/releases/download/bun-v${finalAttrs.version}/bun-linux-x64-baseline.zip";
        hash = "sha256-nYokKSpwaAkCBdqsCloiP19pc29Sh+N7+I07QDHtx1A=";
      };
    };
    updateScript = writeShellScript "update-bun" ''
      set -o errexit
      export PATH="${
        lib.makeBinPath [
          curl
          jq
          common-updater-scripts
        ]
      }"
      NEW_VERSION=$(curl --silent https://api.github.com/repos/oven-sh/bun/releases/latest | jq '.tag_name | ltrimstr("bun-v")' --raw-output)
      if [[ "${finalAttrs.version}" = "$NEW_VERSION" ]]; then
          echo "The new version same as the old version."
          exit 0
      fi
      for platform in ${lib.escapeShellArgs finalAttrs.meta.platforms}; do
        update-source-version "bun" "$NEW_VERSION" --ignore-same-version --source-key="sources.$platform"
      done
    '';
  };
  meta = {
    homepage = "https://bun.sh";
    changelog = "https://bun.sh/blog/bun-v${finalAttrs.version}";
    description = "Incredibly fast JavaScript runtime, bundler, transpiler and package manager – all in one (baseline / no-AVX2 x86_64 build)";
    sourceProvenance = with lib.sourceTypes; [ binaryNativeCode ];
    longDescription = ''
      All in one fast & easy-to-use tool. Instead of 1,000 node_modules for development, you only need bun.

      This variant ships the x86_64-linux *baseline* binary which does not
      require AVX2, for hosts (e.g. some MicroVMs) whose CPU does not
      expose it. All other platforms are identical to upstream nixpkgs bun.
    '';
    license = with lib.licenses; [
      mit # bun core
      lgpl21Only # javascriptcore and webkit
    ];
    mainProgram = "bun";
    maintainers = with lib.maintainers; [
      DAlperin
      jk
      thilobillerbeck
      cdmistman
      diogomdp
    ];
    platforms = builtins.attrNames finalAttrs.passthru.sources;
    # Broken for Musl at 2024-01-13, tracking issue:
    # https://github.com/NixOS/nixpkgs/issues/280716
    broken = stdenvNoCC.hostPlatform.isMusl;
  };
})

the opencode package

References

We ship a modified copy of nixpkgs' opencode package. The package itself is identical to upstream; opencode already takes bun as a callPackage argument, in rixpkgs it's overridden in the opencode package.

MCP servers for utilities

ollama web-search MCP server

References

This is a vendored, patched copy of Ollama's web-search-mcp.py example. Upstream's script tries to import FastMCP from mcp.server.fastmcp and falls back to a low-level Server API using @server.tool(). Both paths are broken in current mcp SDK releases:

  • mcp.server.fastmcp.FastMCP was removed when the SDK reorganized; the high-level class is now MCPServer at mcp.server.

  • The fallback's @server.tool() decorator never existed on the low-level Server — that was a =FastMCP=-only API — so the fallback crashed at import with AttributeError: 'Server' object has no attribute 'tool', which is what opencode surfaced when loading the ollama-search MCP.

This patched copy uses MCPServer directly and drops the broken fallback. Everything else matches upstream. When upstream fixes the import, switch the home.file block above back to pkgs.fetchurl and delete this section.

python:tangle ~/nix/files/ollama-web-search-mcp.py
# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "mcp",
#   "rich",
#   "ollama",
# ]
# ///
"""
MCP stdio server exposing Ollama web_search and web_fetch as tools.

Environment:
- OLLAMA_API_KEY (required): if set, will be used as Authorization header.
"""

from __future__ import annotations

from typing import Any, Dict

from ollama import Client
from mcp.server import MCPServer

client = Client()


def _web_search_impl(query: str, max_results: int = 3) -> Dict[str, Any]:
  res = client.web_search(query=query, max_results=max_results)
  return res.model_dump()


def _web_fetch_impl(url: str) -> Dict[str, Any]:
  res = client.web_fetch(url=url)
  return res.model_dump()


app = MCPServer('ollama-search-fetch')


@app.tool()
def web_search(query: str, max_results: int = 3) -> Dict[str, Any]:
  """
  Perform a web search using Ollama's hosted search API.

  Args:
    query: The search query to run.
    max_results: Maximum results to return (default: 3).

  Returns:
    JSON-serializable dict matching ollama.WebSearchResponse.model_dump()
  """

  return _web_search_impl(query=query, max_results=max_results)


@app.tool()
def web_fetch(url: str) -> Dict[str, Any]:
  """
  Fetch the content of a web page for the provided URL.

  Args:
    url: The absolute URL to fetch.

  Returns:
    JSON-serializable dict matching ollama.WebFetchResponse.model_dump()
  """

  return _web_fetch_impl(url=url)


if __name__ == '__main__':
  app.run()

arcology notes MCP server (opencode harness)

A thin stdio MCP shim wrapping the arcology2 notes CLI for the opencode harness, in the same =MCPServer=/PEP-723 style as the ollama web-search MCP server. The notes tools print a JSON envelope on stdout; every tool here takes the verb's options as strings, runs the CLI, and relays the envelope verbatim — including the risk block, so the risk classifier's fail-closed gating carries through unchanged.

Hermes does NOT use this shim: harness CLI-wrapping there is a skill, not an MCP. This exists only because opencode's native extension surface for command tools IS stdio MCP.

python:tangle ~/nix/files/arcology-notes-mcp.py
# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "mcp",
# ]
# ///
"""
MCP stdio server wrapping `arcology2 notes` for the opencode harness.

Each tool maps to one verb of the CLI and relays its JSON envelope
verbatim, preserving the risk-classifier gating (which fails closed
when ARCOLOGY_RISK_ENDPOINT is unreachable).

Environment:
- ARCOLOGY_RISK_ENDPOINT: Ollama http://host:port serving the classifier
- ARCOLOGY_RISK_MODEL: classifier model name (default hermes3:8b)
"""

from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
from typing import Any

from mcp.server import MCPServer

_ARCOLOGY2 = shutil.which("arcology2")
if _ARCOLOGY2 is None:
    raise SystemExit("arcology2 binary not found on PATH; install it via the VM's home.packages")


def _run_notes(verb: str, options: list[str]) -> Dict[str, Any]:
    cmd = [_ARCOLOGY2, "notes", verb] + options
    proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
    stdout = proc.stdout.strip()
    if not stdout:
        return {
            "ok": False,
            "error": {
                "code": "cli_failed",
                "message": f"arcology2 notes {verb} exited {proc.returncode}: {proc.stderr.strip()}",
            },
        }
    try:
        return json.loads(stdout)
    except json.JSONDecodeError:
        return {
            "ok": False,
            "error": {"code": "cli_bad_output", "message": stdout[:2000]},
        }


def _opt(name: str, value: Any) -> list[str]:
    return [name, str(value)] if value not in (None, "") else []


app = MCPServer('arcology-notes')


@app.tool()
def notes_get(id: str, max_body_bytes: int | None = None) -> dict:
    """Fetch a note by org ID: metadata, child ids, links, refs, plaintext body.

    The body is risk-gated; a redacted note carries only the echoed id
    and the risk block.
    """
    opts = ["--id", id] + _opt("--max-body-bytes", max_body_bytes)
    return _run_notes("get", opts)


@app.tool()
def notes_search(query: str, mode: str = "combined", limit: int = 20) -> dict:
    """Full-text search over the notes; returns ids, titles and outline paths."""
    return _run_notes("search", _opt("--query", query) + _opt("--mode", mode) + _opt("--limit", limit))


@app.tool()
def notes_timeline(from_date: str, to_date: str, kind: str = "both") -> dict:
    """Journal entries and scheduled/deadline notes between two dates (YYYY-MM-DD)."""
    return _run_notes("timeline", [f"--from", from_date, f"--to", to_date, "--kind", kind])


@app.tool()
def notes_capture(title: str, body: str = "", tags: str = "", todo: str = "", date: str = "today") -> dict:
    """Append a capture entry to the daily journal and reindex. Returns the new node id."""
    opts = [f"--title", title, f"--body", body]
    if tags:
        opts += ["--tags", tags]
    if todo:
        opts += ["--todo", todo]
    opts += ["--date", date]
    return _run_notes("capture", opts)


@app.tool()
def notes_sql(query: str, max_rows: int = 200) -> dict:
    """Run a single read-only SELECT against the notes database. Row output is risk-classified."""
    return _run_notes("sql", ["--query", query, "--max-rows", str(max_rows)])


if __name__ == '__main__':
    app.run()