Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help


title: Plugin System — Improvements tags: [architecture, plugins, roadmap] created: 2026-06-12

Plugin System — Improvements

The plugin architecture works and has a real consumer (mun.nix), but the contract had sharp edges. Each item below is a concrete gap observed in the code, with the evidence, the impact, and the fix.

Status (2026-06-12): all six landed. The contract changes are documented in plugins.md. One deliberate deviation from the original plan: #3’s check ships as a build-time warning, not a hard assertion — see that section for why. Implementation notes are inline below each item.

1. Per-agent targeting for plugin-supplied skills and tools

The gap. cowboyLib.forAgents, services.cowboy.skills, and services.cowboy.tools all fan out to every enabled agent. A plugin cannot say “this skill is for the research agent only.” The system already supports multiple agents (services.cowboy.agents.<name>, mode = "multi"), so this is a real limitation, not a hypothetical.

Evidence. modules/skills/home-assistant.nix exists solely to work around this. Its header comment says it all:

# Bypasses global cfg.skills registry to install only into HA-enabled agents

It reimplements the registry’s home-manager fan-out by hand, filtering on a per-agent option (agentOpts.skills.homeAssistant.enable). Every plugin that wants per-agent scoping must copy this boilerplate.

Impact. In single-agent setups (lambda today) it’s harmless. The moment a second agent appears, every globally-registered skill/tool leaks to it — including ones that carry capability (extra additionalTools, packages on PATH). This is the same single-agent assumption flagged throughout the module set.

Fix (landed). cowboyLib gained forAgentsWhere pred f and agentNames (modules/lib/default.nix), and both skills and tools gained an agents = [ … ] selector (empty = all enabled agents, back-compat). The consumer fan-outs build a per-agent skill/tool set (skillsFor / toolsFor) and a per-agent skills.json / tools.json; the per-agent tools manifest also feeds the per-agent WASM override. All pure Nix — the harness already reads per-agent files.

Correction to the original claim: home-assistant.nix does not fully collapse into a registry entry. The agents selector handles per-agent selection, but HA’s prompt is per-agent content (endpoint / token / managed units differ per agent), which a selection-only list can’t express. HA stays as the worked example of forAgentsWhere for content-templated skills.

2. On-demand skill requires are never installed

The gap. modules/skills/default.nix installs a skill’s requires packages into home.packages only when autoLoad = true:

home.packages = lib.concatLists (
  lib.mapAttrsToList (name: skill:
    if skill.autoLoad then skill.requires else [ ]   # <-- on-demand skills get nothing
  ) cfg.skills
);

The plugins doc previously claimed “the requires packages are added to each agent’s home.packages” — true only for the auto-loaded subset.

Impact. An on-demand skill’s prompt tells the agent to run a tool that isn’t on PATH. The agent discovers the gap at runtime, mid-task. mun.nix sidesteps it by pushing krpcPython via forAgents regardless of skill state — meaning the requires field is effectively decorative for its on-demand skills.

Fix (landed). Option (a): modules/skills/default.nix now installs the requires of every skill targeting an agent, regardless of autoLoad (home.packages dedups by store path, so this is cheap). mun.nix no longer needs to push krpcPython by hand — though it still does so for tools outside the skill set (xdotool, uv), which is fine.

3. Phantom systemd units in managedServices

The gap. machines/lambda.nix grants the agent polkit rights over

managedServices.units = [
  ... "mun-ksp.service" "mun-xvfb.service" "mun-setup.service"
  "mun-stream.service" "mun-marimo.service"
];

but mun.nix/services.nix only ever defines one of these (mun-setup). KSP/stream/marimo are launched by the mun-stream shell script, not by units. The other four .service names refer to nothing.

Impact. Polkit rules are generated for units that don’t exist. The agent’s mental model (and the HA-style skill prompt that lists managed units) advertises controls that silently do nothing. It also means the doc’s own example — which shows mun-ksp/mun-stream as proper services with StartLimit* — never matched reality.

Proposal. Pick one model and make it true:

  • Service model (recommended): convert the mun-stream script into real mun-ksp / mun-stream / mun-marimo units with the wantedBy = [ "cowboy.target" ] + StartLimit* discipline the contract already requires. Then managedServices.units is honest and the agent can systemctl restart mun-ksp as advertised.
  • Script model: drop the phantom units from managedServices and give the agent a documented mun-stream invocation instead.

Either way, validate that every managedServices.units entry resolves to a declared unit.

Fix (landed) — prune + warn (not assert). The phantom mun-* units (plus always-phantom steam.service / obs.service) were pruned from machines/lambda.nix; mun.nix keeps only its real units (mun-setup / mun-mods) and the launch path (mun-stream script) is documented. modules/services.nix gained a suffix-aware unit-existence check across services/targets/timers/sockets/mounts/paths, gated by managedServices.validateUnits (default true).

Why a warning, not the planned hard assertion: lambda’s media units are declared conditionallyplex.nix is imported only under sensitive.lib.sellout, readarr further depends on a books flag, and transmission is currently disabled. A hard assertion would break the spoof/template build (where those units legitimately don’t exist) and would force every consumer to mirror that conditional logic into its unit list. A warning surfaces the bug without that fragility. (The validator promptly earned its keep: it flags transmission.service on lambda, which is listed in managedServices but not currently a declared unit — left in place pending a decision to enable transmission or drop the entry.)

4. cowboyLib is too thin to discourage reaching into internals

The gap. cowboyLib exposes only enabledAgents and forAgents. Anything else a plugin needs — the broker user, the netns/proxy state, whether ACLs are on, per-agent home dirs by name — it reads straight out of config.services.cowboy.*. That recouples plugins to cowboy’s internal layout, the exact thing the namespace rule (contract §1) tries to prevent.

Impact. Plugins break when cowboy refactors internals. The [managedServices/daemon/sheepdog scoping refactor already on the books] would move several options; every plugin reading them directly is collateral.

Proposal. Treat cowboyLib as the stable plugin ABI and widen it deliberately:

cowboyLib = {
  inherit enabledAgents forAgents;
  broker        = cfg.broker;
  homeFor       = name: enabledAgents.${name}.homeDirectory;
  userFor       = name: enabledAgents.${name}.user;
  inNamespace   = cfg.secretsProxy.enable;   # is traffic proxied?
  # ... a curated, documented surface
};

Document it as the contract; everything else under services.cowboy.* is internal and may change without notice.

Fix (landed). modules/lib/default.nix now exposes forAgentsWhere, agentNames, broker, userFor/homeFor, and inNamespace alongside the existing enabledAgents/forAgents/singleAgent, documented in plugins.md as the stable ABI.

5. No plugin-side validation or discovery

The gap. Plugins are wired purely by manual imports in the machine config. There’s no assertion surface that a plugin can hook to validate its assumptions (e.g. “stream.enable requires an NVIDIA GPU and a twitch key path that exists”), and no registry of “installed plugins” the agent or operator can introspect.

Impact. Misconfiguration fails late (at activation or, worse, at runtime in the mun-stream script’s preflight command -v checks rather than at eval time).

Proposal. Lightweight, additive:

  • Encourage assertions in plugin modules (none of the current option files assert their cross-field invariants).
  • Optionally, a services.cowboy.plugins.<name> informational registry a plugin sets ({ description, version, extensionPoints }) so the agent’s context and a cowboy plugins CLI subcommand can enumerate what’s installed. Cheap, and turns “what can this agent do here” into a query.

Fix (landed). services.cowboy.plugins.<name> (modules/options/plugins.nix) is the registry; modules/plugins.nix emits /etc/cowboy/plugins.json; the per-agent tools.json system_prompt_suffix surfaces the list to the agent with zero Rust changes; and cowboy plugins (cowboy/cli.py) prints it for operators. mun.nix self-registers. The “encourage assertions” half is seeded with a representative warnings entry in mun.nix (streaming without marimo leaves a blank dashboard panel).

6. Duplicated package construction

The gap. mun.nix calls pkgs.callPackage ./krpc.nix {} and builds krpcPython in both module.nix and services.nix. Same closure, defined twice, kept in sync by hand.

Impact. Minor — Nix dedups the store path — but it’s a maintenance trap (edit one, forget the other) and a bad example for plugin authors who copy mun as the template.

Fix (landed). nix/modules/mun.nix/packages.nix defines krpcPython, krpcPkg, and marimoPairPkg once; module.nix and services.nix both inherit from import ./packages.nix { inherit pkgs; }. This also makes the “Packages layer” from the contract a real file.


Status

#ItemPayoffStatus
3Phantom managed unitscorrectness — agent controls that lieDone — pruned + validator warning
2On-demand requires not installedcorrectness — runtime PATH gapsDone — always installed
6Duplicated package constructionhygiene / better templateDone — packages.nix
1Per-agent skill/tool targetingunblocks multi-agentDone — agents selector (HA stays for content)
4Widen cowboyLib ABIdecouples plugins from refactorsDone — widened
5Plugin validation + discoveryfail-early + introspectionDone — registry + cowboy plugins

Follow-ups left open: decide transmission.service’s fate on lambda (enable or drop); consider promoting the unit validator to a hard assertion on hosts whose unit lists are fully static; and the lazy/ephemeral requires install (#2 option b) if PATH closure size ever matters.