Compare commits
51 Commits
e6ea8a0060
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 55ed1bf5a9 | |||
| ad2e6f5f4a | |||
| 2738061b5d | |||
| ffc9b331ea | |||
| 729c8fdd5f | |||
| e56b710344 | |||
| d782308b42 | |||
| 8e8752e519 | |||
| 58f6b108c3 | |||
| cb26a044d3 | |||
| af643c452d | |||
| 1e80216b07 | |||
| eb67944e68 | |||
| 7bc0d0772c | |||
| ede3c0583f | |||
| aafc68e911 | |||
| 8c85c00ae9 | |||
| c5828e0591 | |||
| 5fd8de031d | |||
| de99b4a89e | |||
| f5d799c64b | |||
| e143495d6c | |||
| 3c4eaec76b | |||
| 007ba81c02 | |||
| 3977ed6822 | |||
| 289ea1344c | |||
| 63da676b5a | |||
| 8fc816bcd9 | |||
| 585d4919e7 | |||
| 6544d3d8a0 | |||
| 2fed687a00 | |||
| 36d7a53029 | |||
| 6422bb96f2 | |||
| 9ed4809837 | |||
| 6945c29a47 | |||
| 521e4c7fb6 | |||
| b8bb26da75 | |||
| ef1eebda58 | |||
| 06e327ed85 | |||
| adcb7bfd77 | |||
| 2b957c7f09 | |||
| 7a97ee4e31 | |||
| 3e3975c724 | |||
| f218e47814 | |||
| 53a070a59a | |||
| 969737b6b5 | |||
| 0b7d409fbc | |||
| d637d3e7f6 | |||
| ab9b9e9f8f | |||
| 78ab95922f | |||
| 7edc1ce94b |
@@ -1,72 +0,0 @@
|
|||||||
# NixOS Dotfiles
|
|
||||||
|
|
||||||
A single flake that builds every machine the user owns — laptop, desktop, and three servers — from one shared, modular configuration.
|
|
||||||
|
|
||||||
## Language
|
|
||||||
|
|
||||||
**Host**:
|
|
||||||
One physical machine the flake builds a NixOS configuration for. Each Host has a directory under `hosts/` holding its machine-specific `hardware-configuration.nix` and its choice of enabled Modules.
|
|
||||||
_Avoid_: machine, node, system, box
|
|
||||||
|
|
||||||
**Module**:
|
|
||||||
A single `.nix` feature file under `modules/` that declares an `enable` option and the configuration it turns on. Every Module is always imported but stays inert until a Host enables it.
|
|
||||||
_Avoid_: component, package, plugin
|
|
||||||
|
|
||||||
**Guest**:
|
|
||||||
A reusable, machine-independent definition under `guests/` that bundles the Modules it runs inside an isolated NixOS instance, realized on a Host as a nested container.
|
|
||||||
Its `backend` defaults to `container` (systemd-nspawn).
|
|
||||||
`microvm` is a reserved backend value that is not yet built.
|
|
||||||
A Guest follows the Modules convention wholesale: the file-or-folder layout, the Namespace convention (`guests/media/jellyfin.nix` declares `guests.media.jellyfin`), and the Enable convention (imported always, inert until a Host sets its `enable`).
|
|
||||||
The Guest owns only its interior Modules.
|
|
||||||
The Host that enables it supplies the machine-specific placement, such as its VLAN, pool mounts, and resource caps.
|
|
||||||
_Avoid_: container, VM, instance, LXC, appliance
|
|
||||||
|
|
||||||
**Skeleton**:
|
|
||||||
The flake's plumbing — the Auto-loader, the helper lib, the flake inputs/overlays, and the shared base config — as distinct from the Modules that sit on top of it.
|
|
||||||
The shared base config splits in two: a host base (`system.nix`) and a slim guest-base that every nested Guest stands on.
|
|
||||||
The host base carries host-only machinery, such as the bootloader, hardware profile, host identity, and the boot and garbage-collection timers.
|
|
||||||
The guest-base carries only what a nested service needs and auto-enables the `toolkit` bundle and `modules.ssh`.
|
|
||||||
Both include the primary user, home-manager, and the shared overlays.
|
|
||||||
_Avoid_: framework, core, base, scaffolding
|
|
||||||
|
|
||||||
**toolkit**:
|
|
||||||
A deliberate bundle Module (`modules.toolkit`) that turns on the baseline interactive environment — fish, tmux, nvim, git, direnv — as one unit, so any Host or Guest shell feels identical.
|
|
||||||
The guest-base auto-enables it; a Host enables it explicitly like any other Module, keeping the Host a full checklist.
|
|
||||||
Distinct from the grouping-directory enables ADR 0004 rejected, since this is a bundle wanted as a unit.
|
|
||||||
_Avoid_: base, workstation, essentials
|
|
||||||
|
|
||||||
**Auto-loader**:
|
|
||||||
The lib code that recursively discovers and imports every Module under `modules/`, every Host under `hosts/`, and every Guest under `guests/`, so new files wire themselves in without manual `imports` edits.
|
|
||||||
_Avoid_: loader, importer, scanner
|
|
||||||
|
|
||||||
**Enable convention**:
|
|
||||||
The rule that every Module is imported unconditionally and guards its own body with `mkIf config.modules.<path>.enable`, so a Host reads as a checklist of `enable = true` flags.
|
|
||||||
_Avoid_: feature flag, toggle, opt-in
|
|
||||||
|
|
||||||
**Namespace convention**:
|
|
||||||
The rule that a Module's option path mirrors its directory path under `modules/`, so a file's location is its namespace.
|
|
||||||
A file whose name matches its enclosing directory is that directory's index node, declaring the directory's own segment rather than a doubled one.
|
|
||||||
A directory with no such file is a pure namespace prefix that carries no aggregate enable.
|
|
||||||
_Avoid_: option tree, module path, config key
|
|
||||||
|
|
||||||
**admin identity**:
|
|
||||||
The age identity held only in the operator's password manager, never committed, that is a recipient of every secrets file.
|
|
||||||
It is the recovery path for any wiped machine and the credential that authorizes registering a new host.
|
|
||||||
_Avoid_: master key, admin key, root key
|
|
||||||
|
|
||||||
**host identity**:
|
|
||||||
The dedicated age key on one machine's encrypted root, generated there and never transmitted, that decrypts that machine's own secrets and the shared file.
|
|
||||||
Deliberately distinct from the machine's SSH host key.
|
|
||||||
_Avoid_: machine key, node key, host key
|
|
||||||
|
|
||||||
**secrets file**:
|
|
||||||
One sops-encrypted file in the repo, encrypted to the admin identity plus whichever hosts may read it. Either shared across every host or specific to one.
|
|
||||||
_Avoid_: vault, secret store, keyring
|
|
||||||
|
|
||||||
**unstable overlay**:
|
|
||||||
The overlay exposing `nixpkgs-unstable` packages as `unstable.<name>`, used to pull an individual package fresher than the `nixos-unstable` base.
|
|
||||||
_Avoid_: bleeding-edge, latest
|
|
||||||
|
|
||||||
**stable overlay**:
|
|
||||||
The overlay exposing the latest stable release (`nixos-26.05`) as `stable.<name>`, used to pin an individual package to the rock-solid release from the `nixos-unstable` base.
|
|
||||||
_Avoid_: LTS, release channel
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
---
|
|
||||||
status: superseded by ADR-0002
|
|
||||||
---
|
|
||||||
|
|
||||||
# Use sops-nix for secrets
|
|
||||||
|
|
||||||
The repo is public, so no secret — including password hashes and the WireGuard/ProtonVPN key — may be committed in plaintext. We manage all secrets with **sops-nix**: encrypted into the repo and decrypted per-host at activation via an age key derived from each machine's SSH host key.
|
|
||||||
|
|
||||||
We chose sops-nix over agenix for its multi-recipient encryption (one secret readable by both a host and the admin laptop) and its grouped-file editing workflow, which scale better across the planned five hosts with a mix of shared and per-host secrets. The cost is slightly more upfront machinery than agenix's one-file-per-secret model.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- User/root passwords use `hashedPasswordFile` backed by a sops secret, never a committed hash.
|
|
||||||
- Each new host must have its SSH host public key registered as a recipient before it can decrypt its secrets.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
status: accepted
|
|
||||||
---
|
|
||||||
|
|
||||||
# Two-tier age identities, secrets in the public repo
|
|
||||||
|
|
||||||
Secrets are encrypted with sops-nix into this public repo and decrypted by a two-tier set of age identities: one **admin identity**, stored only in Proton Pass and never committed, which is a recipient of every secrets file; and one **host identity** per machine, a dedicated age key generated on that machine's encrypted root, which reads only its own secrets plus the shared file.
|
|
||||||
The admin identity makes secrets recoverable after any machine is wiped and is the credential that authorizes registering a new host; the host identities keep a compromised server from decrypting the laptop.
|
|
||||||
Deliberately, a host identity is *not* derived from its SSH host key — that decoupling is what lets the SSH host keys themselves be secrets, so they survive a reimage instead of being regenerated.
|
|
||||||
|
|
||||||
This supersedes ADR 0001, whose choice of sops-nix over agenix still holds — the shared-plus-per-host file split with overlapping recipients is exactly the multi-recipient, grouped-file model that decided against agenix — but whose key-derivation mechanism is replaced.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
- **A separate private repository for secrets.** Rejected: cloning it needs credentials that would themselves be bootstrap material during an install, reintroducing a hand-carried secret to protect ciphertext that is already safe to publish.
|
|
||||||
- **A passphrase-encrypted admin identity committed to the repo.** Rejected: in a public repo it is offline-brute-forceable indefinitely, whereas a password manager provides the same protection with rate limiting.
|
|
||||||
- **Deriving host identities from SSH host keys**, as ADR 0001 specified. Rejected: it forces new host keys on every reimage, which means re-keying every secret, and it makes storing the host keys as secrets circular.
|
|
||||||
- **A single admin identity for all hosts, with no per-host identities.** Rejected: with three servers planned, it gives every machine the ability to decrypt every other machine's secrets.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Every secrets file must include the admin identity as a recipient. A file readable only by its own host becomes permanently unrecoverable the moment that machine is wiped.
|
|
||||||
- The admin identity is the single point of recovery, and its durability is now a property of Proton Pass rather than of any machine or repository.
|
|
||||||
- A host must have its identity provisioned and registered *before* its first boot, because the login password now arrives only from a decrypted secret and there is no fallback credential.
|
|
||||||
- Registering a new host is a re-key of each file's data key, not a re-encryption of its values, so the cost stays constant as the fleet grows.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
---
|
|
||||||
status: accepted
|
|
||||||
---
|
|
||||||
|
|
||||||
# Hyprland as the keyboard-driven desktop compositor
|
|
||||||
|
|
||||||
The graphical desktop is built on **Hyprland**, a keyboard-driven Wayland tiling compositor, as a single choice serving both the laptop (neogaia) and the future desktop (zeus).
|
|
||||||
It matches how the operator already works: an i3 model of numbered workspaces and manual tiling, ported to bindings reachable entirely on a 60% keyboard.
|
|
||||||
Among true tilers it comes closest to "just works" through its cohesive first-party companion tools (lock, idle, wallpaper, portal) and a large ecosystem, which buys down the assembly-and-breakage cost that made past minimal tiling setups expensive for the operator.
|
|
||||||
|
|
||||||
The gaming and driver dimension does not constrain the choice, because both Hosts drive Wayland without caveats: neogaia is Intel and zeus is AMD.
|
|
||||||
Notably zeus is AMD, not Nvidia — Raichu is the only Nvidia machine, and it is a server with no desktop — so no Nvidia-on-Wayland pressure shapes the decision.
|
|
||||||
With gaming survival off the table, the choice rests on workflow and low-friction rather than on tolerating a hostile driver.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
- **Sway.** Rejected: its i3-faithful minimalism is precisely what historically cost the operator hours of assembly and breakage.
|
|
||||||
On AMD it games fine, but its only remaining edge over Hyprland was stability and purity — which the repo's pinning already provides, and which the operator's stated "just works" priority actively discounts.
|
|
||||||
- **KDE Plasma.** Rejected: the most integrated and lowest-friction option, the best AMD gaming desktop, and the operator's prior environment — but mouse-first at its core and only keyboard-navigable at the margins, which works against the primary keyboard-first requirement.
|
|
||||||
Its custom-tile-layout feature is an approximation of tiling on a floating desktop, not real automatic tiling.
|
|
||||||
- **niri.** Rejected: its scrollable-tiling paradigm abandons numbered workspaces, which breaks the operator's core muscle memory of switching by number.
|
|
||||||
It also has the smallest community of the candidates, a low-friction risk for a daily-driver desktop.
|
|
||||||
- **A different compositor per Host** (a keyboard-pure laptop plus a separate gaming desktop). Rejected: it doubles the configuration and maintenance and defeats the goal of one transferable setup.
|
|
||||||
It is unnecessary once AMD removes any gaming penalty from a keyboard-first compositor.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- One desktop Module set serves both Hosts.
|
|
||||||
zeus adopts the identical desktop by enabling a single flag, with battery-sensitive knobs such as blur flipped on for its AMD headroom.
|
|
||||||
- Hyprland's churn and occasional breakage are absorbed by the pinned, declarative, reversible configuration rather than by live fixing, so upgrades happen on the operator's schedule.
|
|
||||||
- The desktop's lock, idle, keybind syntax, and portal are Hyprland-specific, so a future move to another compositor would be a rewrite rather than a swap.
|
|
||||||
This is the accepted cost of the first-party-cohesion benefit.
|
|
||||||
- Hyprland is taken from nixpkgs, with no compositor plugins this pass.
|
|
||||||
Adopting the upstream Hyprland flake later, for a plugin or a bleeding-edge feature, is a contained change that mirrors the existing chaotic-nyx input pattern (an input that must not follow nixpkgs, carrying its own binary cache).
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
status: accepted
|
|
||||||
---
|
|
||||||
|
|
||||||
# A Module's option namespace mirrors its directory
|
|
||||||
|
|
||||||
A Module's option path mirrors its directory path under `modules/`, so a file's location on disk is its namespace: `modules/agents/tools/gitea-axi.nix` declares `modules.agents.tools.gitea-axi`, and a subfolder like `agents/` or `tools/` is a real namespace segment, not a cosmetic grouping.
|
|
||||||
A file whose name matches its enclosing directory is that directory's index node, declaring the directory's own segment — its `enable` or aggregator — rather than a doubled segment, so `desktop/hyprland/hyprland.nix` owns `modules.desktop.hyprland` while `desktop/hyprland/hypridle.nix` nests under it as `modules.desktop.hyprland.hypridle`.
|
|
||||||
|
|
||||||
We chose this nested-mirrors-directory shape over the previous flat names (`modules.claude-code`, `modules.gitea-axi`) because the flat scheme let a Module sit anywhere on disk regardless of its option path, so the tree stopped predicting where a namespace lived.
|
|
||||||
Mirroring makes the two the single fact.
|
|
||||||
A pure grouping directory (`agents/`, `tools/`) contributes a namespace segment but declares no aggregate `enable`: agents are enabled à la carte, so there is deliberately no `modules.agents.enable` that would turn on a bundle nobody wants as a unit.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
- **Flat, location-independent names** (the prior state). Rejected: a Module's option path was unconstrained by its file's location, so the directory tree and the option tree drifted and neither could be read off the other.
|
|
||||||
- **A subfolder as cosmetic grouping only**, with the option path skipping the folder (`agents/pi.nix` → `modules.pi`). Rejected: it reintroduces the same drift for grouped Modules and makes the folder a lie the namespace does not tell.
|
|
||||||
- **An aggregator at every grouping level** (`modules.agents.enable`). Rejected: the agent Modules have no meaningful "all agents" bundle, and an aggregate enable there would invite turning on tools no Host wants together.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- The `agents/` group carries `claude-code`, `pi`, `skills`, and `tools/gitea-axi`, each enabled individually under `modules.agents.*`, with no `modules.agents.enable`.
|
|
||||||
- The index-file rule means adding a knob to an existing group (a new `desktop/hyprland/*.nix`) nests automatically without a naming decision, while a new top-level Module names its own segment.
|
|
||||||
- The `skills` Module remains the one deliberate exception to the Enable convention — it wires unconditionally — which the namespace convention does not change.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
status: accepted
|
|
||||||
---
|
|
||||||
|
|
||||||
# Stock Firefox with policy-installed extensions
|
|
||||||
|
|
||||||
The browser Module ships **stock mainline Firefox** (`pkgs.firefox`, the release train), and installs its three extensions — an ad and content blocker, the operator's password manager, and a video sponsor-skipper — through Mozilla's enterprise `ExtensionSettings` policy, keyed by add-on id with an install URL and `installation_mode = "force_installed"`.
|
|
||||||
Firefox fetches each signed add-on from Mozilla's add-on site at runtime and enables it automatically.
|
|
||||||
|
|
||||||
We chose this over an ESR, unbranded, or Developer Edition build carrying hash-pinned add-on packages from the Nix store.
|
|
||||||
Stock mainline Firefox refuses to load unsigned locally-built add-ons, so the pinned-package path forces the browser variant: it works only on a build that relaxes signature enforcement, which the mainline release does not.
|
|
||||||
Pairing the variant to the extension mechanism makes this the pivotal, hard-to-reverse decision — the choice of build dictates the whole extension story — so it is recorded here rather than left implicit in the Module.
|
|
||||||
|
|
||||||
The trade-off is deliberate.
|
|
||||||
The policy path gives up build-time reproducibility of the extension binaries, and needs network on first launch to populate them, in exchange for staying on current mainline Firefox with add-ons that are actually enabled and no new flake input.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
- **Stock mainline Firefox with policy-installed extensions** (chosen).
|
|
||||||
Current release train, no signature-enforcement caveat, no extra flake input.
|
|
||||||
The extension binaries are fetched signed at runtime rather than pinned, so their exact versions are not reproducible from the flake and first launch needs network.
|
|
||||||
- **ESR or unbranded Firefox with hash-pinned add-on packages** (e.g. via a NUR add-ons input).
|
|
||||||
Rejected: it buys reproducible extension binaries but drags in an older or unusual browser variant to satisfy the signature check the mainline build enforces, plus a new flake input to maintain, for a browser the operator wants on the mainline feature and security cadence.
|
|
||||||
- **Stock Firefox with extensions installed by hand.**
|
|
||||||
Rejected: the state would live outside the flake, would not survive a reimage, and defeats the point of declaring the browser at all.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- The Module needs no new flake input and no add-on package set.
|
|
||||||
The extension list is three id/URL pairs under the enterprise policy.
|
|
||||||
- Extension versions are whatever Mozilla currently serves, not a pinned hash, so the browser tracks upstream add-on updates automatically and the flake does not gate them.
|
|
||||||
- First launch after a fresh build requires network to fetch the add-ons.
|
|
||||||
An offline first boot comes up with the extensions not yet present, populating them once online.
|
|
||||||
- Moving to a pinned-package posture later would mean changing the browser variant as well, since the two are coupled — the reason this is captured as a decision rather than a detail.
|
|
||||||
- The no-pinned-package rule scopes to the three functional extensions, which is where the signature-enforcement conflict bites.
|
|
||||||
Nord chrome theming comes from the Stylix Firefox Color add-on, a signed add-on that Stylix pins and manages, so it loads on stock mainline Firefox and adds no flake input of ours.
|
|
||||||
That is a bounded, deliberate exception, not a reversal: it is what lets the browser be themed from the shared Stylix scheme without hand-written chrome CSS.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
status: accepted
|
|
||||||
---
|
|
||||||
|
|
||||||
# A Guest is a third concept beside Host and Module
|
|
||||||
|
|
||||||
The flake gains a third first-class concept, the Guest, beside the Host and the Module.
|
|
||||||
A Guest is a reusable, machine-independent definition under `guests/` that bundles the Modules it runs inside an isolated NixOS instance, realized on a Host as a nested container.
|
|
||||||
A Host composes both Modules and Guests: a Module turned on directly is a host-level install, and a Guest is a service running in its own isolated instance, so a Host still reads as one flat checklist of everything it carries.
|
|
||||||
|
|
||||||
A Guest follows the Modules convention wholesale.
|
|
||||||
The Auto-loader discovers `guests/` as a third kind, the Namespace convention and file-or-folder rule apply unchanged (`guests/media/jellyfin.nix` declares `guests.media.jellyfin`), and a Guest is Module-shaped: it declares its own `guests.<path>` option namespace with an `enable` and its placement fields and guards its body on that `enable` per the Enable convention.
|
|
||||||
The single difference from a Module is the payload — a Module's body merges settings into the Host, while a Guest's body realizes a nested container running the Guest's interior.
|
|
||||||
|
|
||||||
A Guest is backend-agnostic through a `backend` field but only the `container` backend (systemd-nspawn) is built now; `microvm` is a reserved value for a future hard-isolation backend.
|
|
||||||
A Guest is sealed and singleton: its interior Modules are fixed in the Guest file and not overridable by a Host, which supplies only placement, and a Guest is instantiated at most once per Host.
|
|
||||||
Every Guest stands on a slim guest-base, distinct from the host base carved out of the shared base config, and imports the full `modules/` tree so any Module is available inside it.
|
|
||||||
|
|
||||||
We chose this because the homelab this flake is growing to build runs services as isolated guests that are first-class citizens on a VLAN-tagged network, and the two-concept model had no way to say "run this service in its own instance, on this VLAN, with these pool mounts" declaratively or to reuse that definition across machines.
|
|
||||||
Making the Guest a peer of Host and Module — same Auto-loader, same conventions, same checklist — adds the capability without adding a second mental model, and keeps the imperative container lifecycle and mutable drift of the Proxmox setup it replaces out of the flake.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
- **Inline `containers.<name>` per Host.** The platform's native nested-container option, declared directly inside each Host. Rejected: a guest would be tied to one Host with no reuse, and the machine-independent identity of an appliance would be entangled with one machine's config, the same drift ADR 0004 removed for Modules.
|
|
||||||
- **Incus or another imperative container stack.** A maintained LXC-style manager on the Host. Rejected: its instance lifecycle is imperative and its state mutable, which is precisely the Proxmox property the migration exists to eliminate; guests would not be declared in the flake.
|
|
||||||
- **A microvm-first Guest.** Realize every Guest as a hard-isolated micro VM from the start. Deferred, not chosen for now: the operator's guests are the soft-isolation class the Proxmox LXCs already are, and a shared-kernel container backend is the like-for-like replacement; the backend field reserves `microvm` for when a guest genuinely needs a distinct kernel.
|
|
||||||
- **A Guest as a parameterized template with Host-declared instances.** Let one Guest file be stamped out as many named instances per Host. Rejected: it breaks "follow the Modules convention completely" by making a Guest a non-singleton template with its own instance sublevel, a fourth shape; multiplicity is instead expressed as separate Guests over shared, configurable service Modules.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- The Skeleton grows a Guest realization: the Auto-loader discovers `guests/`, a Guest's interior compiles into a nested container, and placement fields wire VLAN attachment, MAC pinning, bind mounts, secret mounts, unit caps, and the nesting prerequisites.
|
|
||||||
- The shared base config splits into a host base (`system.nix`) and a slim guest-base; both include the primary user, home-manager, and the shared overlays, and the guest-base auto-enables the `toolkit` bundle and `modules.ssh`.
|
|
||||||
- Guests attach to host-level foundations declared once per Host: `modules.network` for the trunk and per-VLAN bridges, and `modules.storage.zfs` with a shared fixed-gid `storage` group for identity-mapped pool writes.
|
|
||||||
- OCI software has a declarative home without a new mechanism: a Guest with `nesting` runs Podman in its interior.
|
|
||||||
- The `microvm` backend, multi-instance Guests, and the concrete homelab Host with its real trunk, VLAN, pool, and MAC values remain future work.
|
|
||||||
1
.claude/skills
Symbolic link
1
.claude/skills
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../.agents/skills
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
# Guests
|
|
||||||
|
|
||||||
## Problem Statement
|
|
||||||
|
|
||||||
The user runs a homelab on Proxmox with LXC system containers and Podman, replacing it with a fully declarative NixOS configuration where both the machines and the services they host are NixOS, built from this one flake.
|
|
||||||
Each service today is an isolated guest that is a first-class citizen on a VLAN-tagged LAN: it has its own MAC address and its own IP on a specific tagged VLAN, and it writes to shared ZFS pools without permission errors.
|
|
||||||
The flake currently has only two concepts, the Host and the Module, and a Module can only be turned on at host level.
|
|
||||||
There is no way to express "run this service isolated in its own guest, on this VLAN, with these pool mounts" declaratively, and no way to reuse such a definition across machines.
|
|
||||||
The Proxmox setup the user is leaving also imposes an imperative container lifecycle and mutable state that drifts outside version control, which is the thing the migration exists to eliminate.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
Introduce a third concept alongside Host and Module: the Guest.
|
|
||||||
A Guest is a reusable, machine-independent definition that bundles the Modules it runs inside an isolated NixOS instance, realized on a Host as a nested container.
|
|
||||||
A Host composes both Modules and Guests and reads as one flat checklist, where a Module turned on directly is a host-level install and a Guest is a service running in its own isolated instance.
|
|
||||||
Each Guest becomes a first-class L2 citizen on a chosen tagged VLAN with its own MAC and its own IP, writes to shared pools through a common storage group with no permission juggling, and carries its own resource caps and secrets.
|
|
||||||
Every Guest ships with the user's baseline interactive toolset and SSH access, so any guest the user shells into is immediately a workable environment.
|
|
||||||
A Guest can also run Podman or other OCI containers in its interior, which is the declarative home for image-only software that is not worth reimplementing in Nix.
|
|
||||||
The foundations a Guest stands on — declarative VLAN and bridge networking, ZFS pool import, and a shared write convention — are themselves host-level Modules, so a Host declares its trunk, its VLANs, and its pools once and every Guest attaches to them.
|
|
||||||
|
|
||||||
## User Stories
|
|
||||||
|
|
||||||
1. As the operator, I want to define a service as a Guest in its own file, so that the service and everything it runs are one reusable, version-controlled fact.
|
|
||||||
2. As the operator, I want a Guest to follow the same file, folder, and namespace conventions as a Module, so that I never learn a second layout and a Guest's location is its namespace.
|
|
||||||
3. As the operator, I want a Host to turn a Guest on with `enable` exactly as it turns a Module on, so that a Host stays a single flat checklist of everything it carries.
|
|
||||||
4. As the operator, I want a Module placed directly on a Host to mean a host-level install and a Guest to mean an isolated instance, so that the same checklist expresses both placements without ambiguity.
|
|
||||||
5. As the operator, I want a Guest to give its service its own MAC and its own IP on a specific tagged VLAN, so that my VLAN-segmented network treats each service as a distinct L2 citizen, exactly as Proxmox did.
|
|
||||||
6. As the operator, I want to reuse the MAC addresses my existing containers already use, so that my router's DHCP reservations keep working and the migration needs no network reconfiguration.
|
|
||||||
7. As the operator, I want a Guest whose MAC I did not set to still get a stable, readable MAC, so that I can add a reservation for a new guest without hand-assigning addresses.
|
|
||||||
8. As the operator, I want a Guest to take its address by DHCP by default and optionally a static address, so that IP management stays centralized at my router where it already lives.
|
|
||||||
9. As the operator, I want a build-time error when a Guest names a VLAN its Host has not declared, so that a misplacement fails at evaluation rather than as a broken bridge at runtime.
|
|
||||||
10. As the operator, I want a Guest to mount shared pool paths at any granularity, a single folder or a whole pool, read-only or read-write, so that each service sees exactly the data it should.
|
|
||||||
11. As the operator, I want every Guest's service to write to shared pools without permission errors, so that I never repeat the Proxmox idmap dance.
|
|
||||||
12. As the operator, I want a Guest to receive only the decrypted secrets it names, so that services get their credentials while no guest ever holds a decryption key.
|
|
||||||
13. As the operator, I want to cap a Guest's memory, CPU, and process count, so that one misbehaving service cannot starve its Host.
|
|
||||||
14. As the operator, I want every Guest to come with fish, tmux, nvim, git, and direnv, so that any guest I shell into feels like my own machine.
|
|
||||||
15. As the operator, I want to reach a Guest both directly over SSH with my keys and from its Host with `machinectl`, so that I always have a way in whether or not the network path is open.
|
|
||||||
16. As the operator, I want a Guest to run Podman and OCI containers in its interior by turning on a nesting capability, so that image-only software has a declarative home without a separate mechanism.
|
|
||||||
17. As the operator, I want the same Guest definition to be deployable on more than one Host by declaring it there with that Host's placement, so that an appliance is portable between machines.
|
|
||||||
18. As the operator, I want the networking, storage, and write conventions to be host-level Modules I declare once per Host, so that Guests attach to shared foundations instead of each re-specifying the machine.
|
|
||||||
19. As the operator, I want a Guest's innards fixed in the Guest file and only its placement supplied by the Host, so that a Guest behaves identically wherever it runs and reads honestly in isolation.
|
|
||||||
20. As the operator, I want to build a Host and know its Guests evaluate and its placements are consistent before I deploy, so that a rebuild is trustworthy.
|
|
||||||
|
|
||||||
## Implementation Decisions
|
|
||||||
|
|
||||||
### The Guest concept
|
|
||||||
|
|
||||||
- A **Guest** is a new, auto-loaded kind of definition, resolved in `CONTEXT.md` and formalized in ADR 0006.
|
|
||||||
The Auto-loader discovers every Guest under `guests/` as a third kind alongside Modules and Hosts.
|
|
||||||
- A Guest follows the **Namespace convention** and the file-or-folder rule of a Module verbatim: a plain Guest is one file whose location is its namespace, and a Guest that needs auxiliary files becomes a folder, with subfolders as namespace segments and the same index-node rule (per ADR 0004).
|
|
||||||
- A Guest is **Module-shaped**: it declares its own `guests.<path>` option namespace carrying an `enable` plus its placement fields, and guards its body on that `enable` following the **Enable convention**.
|
|
||||||
The one difference from a Module is the payload: a Module's body merges settings into the Host, whereas a Guest's body **realizes a nested container** running the Guest's interior.
|
|
||||||
- A Guest is **backend-agnostic** through a `backend` field, but only the `container` backend (systemd-nspawn, via the platform's native nested-container mechanism) is built.
|
|
||||||
`microvm` is a reserved backend value that is not implemented in this work.
|
|
||||||
- A Guest is **sealed and singleton**: the Modules a Guest runs are fixed in the Guest file and are not overridable by a Host, and a Guest is instantiated at most once per Host, keyed by its namespace path.
|
|
||||||
Running more than one instance of a service on a Host is out of scope for this work.
|
|
||||||
|
|
||||||
### Placement interface (Host-side)
|
|
||||||
|
|
||||||
A Host instantiates a Guest by setting fields under `guests.<path>`.
|
|
||||||
The Guest owns its interior Modules; the Host owns only this placement.
|
|
||||||
|
|
||||||
| Field | Meaning | Default |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `enable` | Turn the Guest on for this Host | off |
|
|
||||||
| `backend` | Realization backend | `container` |
|
|
||||||
| `vlan` | Tagged VLAN the Guest lives on; maps to the Host's `br-vlan<id>` bridge by convention | required when networked |
|
|
||||||
| `mac` | The Guest's MAC address; set it to reuse an existing address | derived deterministically and surfaced when unset |
|
|
||||||
| `address` | Static address on the VLAN | unset, meaning DHCP |
|
|
||||||
| `mounts` | Map of guest path to host path, each with a per-mount `readOnly` | read-write per mount |
|
|
||||||
| `secrets` | Names of secrets the Guest needs | none |
|
|
||||||
| `limits` | `memory`, `cpu`, `tasksMax` caps applied to the guest's unit | uncapped |
|
|
||||||
| `nesting` | Grant the nested-container prerequisites so the interior can run Podman/OCI | off |
|
|
||||||
| `autoStart` | Start the Guest at boot | on |
|
|
||||||
|
|
||||||
- The `vlan` field maps to a bridge by the `br-vlan<id>` **naming convention**, so a Guest states only which VLAN it lives on.
|
|
||||||
- A **build-time assertion** ties a Guest's `vlan` to the set of VLANs its Host's network foundation declares, so a Guest on an undeclared VLAN fails the Host build with a clear message.
|
|
||||||
- The `mac` field is pinned inside the guest through the guest's own systemd-networkd, which is the only way a nested-container MAC is stable; an unset `mac` derives a stable address from the Guest's namespace path in a locally-administered range, readable via evaluation so the operator can add a reservation.
|
|
||||||
- The `mounts` field is realized as the nested container's bind mounts and defaults to read-write, matching the migration reality that services must write to pools.
|
|
||||||
- The `nesting` field is what makes the OCI fallback a plain Guest: the Skeleton emits the nested-container cgroup-delegation and capability prerequisites once, so the operator flips one boolean and the interior's `oci-containers` runtime works, with Podman as the default runtime.
|
|
||||||
|
|
||||||
### Bases and the shared environment
|
|
||||||
|
|
||||||
- The shared base config **splits in two** (recorded under the Skeleton term): a **host base** that carries host-only machinery (bootloader, hardware profile, host identity, boot and garbage-collection timers), and a slim **guest-base** that every nested Guest stands on.
|
|
||||||
- Every Guest **imports the full `modules/` tree**, so any Module is available to enable inside a Guest; only Modules whose needs the base meets are enabled in practice.
|
|
||||||
- A Guest gets **home-manager and the same primary user** as a Host, which makes every Module placement-agnostic and removes any need for a host-versus-guest Module taxonomy.
|
|
||||||
- The **guest-base auto-enables** the `toolkit` bundle and `modules.ssh`, so every Guest has the baseline toolset and SSH access without per-Guest wiring.
|
|
||||||
Reaching a Guest by `machinectl` from its Host needs no Guest configuration and is the always-available fallback.
|
|
||||||
|
|
||||||
### New and modified Modules
|
|
||||||
|
|
||||||
- **`modules.toolkit`** (new): a deliberate bundle Module turning on fish, tmux, nvim, git, and direnv as one unit.
|
|
||||||
The guest-base auto-enables it; a Host enables it explicitly, keeping the Host a full checklist.
|
|
||||||
This is a bundle wanted as a unit, distinct from the grouping-directory enables ADR 0004 rejected.
|
|
||||||
- **`modules.network`** (new): the host-level networking foundation.
|
|
||||||
A Host declares its trunk interface and the set of VLANs to materialize, and the Module emits one bridge per tagged VLAN with systemd-networkd and manages the Host's own management address.
|
|
||||||
- **`modules.storage.zfs`** (new): the host-level pool import.
|
|
||||||
A Host declares its host id, the pools to import, and their dataset mountpoints; the pools are durable state that is imported, never rebuilt.
|
|
||||||
- **A shared `storage` group** with a fixed gid in the shared base gives 1:1 ownership between Host and Guest.
|
|
||||||
Because the container backend uses identity mapping, a guest service that writes as the `storage` group lands on the pool as that same group, which is the entire "no permission errors" mechanism.
|
|
||||||
- **`modules.ssh`** (reused): the guest-base enables it in a guest flavor, with the operator's authorized keys and a self-generated host key, since a Guest does not carry a per-Guest host identity the way a Host does.
|
|
||||||
- **The Skeleton** grows the Guest realization: the Auto-loader's discovery of `guests/`, the compilation of a Guest's interior into a nested container, and the wiring of placement fields (VLAN attachment, MAC pinning, bind mounts, secret mounts, unit caps, nesting prerequisites).
|
|
||||||
|
|
||||||
### Secrets
|
|
||||||
|
|
||||||
- The **Host is the sole decryptor**, consistent with the host identity of ADR 0002 and the existing sops-nix setup.
|
|
||||||
The Host decrypts, and a Guest receives only the specific secret files it names, read-only bind-mounted in, with ownership aligned by the identity mapping.
|
|
||||||
A Guest holds no age key.
|
|
||||||
|
|
||||||
## Testing Decisions
|
|
||||||
|
|
||||||
A good test here exercises **external, observable behavior of a Guest and its foundations**, not the internal shape of the generated nested-container config.
|
|
||||||
The load-bearing behaviors are: a Guest presents its own MAC and its own IP on the correct tagged VLAN, a guest service writes to a shared-group mount without permission error, the baseline toolset and SSH access are present, and a misplaced Guest fails the build.
|
|
||||||
|
|
||||||
Two seams, the fewest that cover the work:
|
|
||||||
|
|
||||||
1. **The Host toplevel build via `nix flake check`** — the existing, primary seam every Host already has.
|
|
||||||
This is where a Guest's evaluation, its placement fields, the base split, the Auto-loader wiring, and the `vlan`-against-declared-VLANs assertion are all verified.
|
|
||||||
Cheap targeted evaluations of derived values (a Guest's resolved bridge attachment, its derived MAC, the shared `storage` gid) ride on this same seam.
|
|
||||||
2. **One NixOS VM integration test**, added to the flake's `checks` so `nix flake check` runs it — a new seam, and the highest behavioral one available.
|
|
||||||
A single harness boots the `modules.network` foundation and one sample Guest and asserts the three hard requirements together: the guest has its own MAC, gets its own IP on a tagged VLAN across a virtual L2 segment, and can write to a bind-mounted directory owned by the shared `storage` group.
|
|
||||||
|
|
||||||
Prior art: the flake's `checks.<host>` toplevel builds are the established evaluation seam; the upstream NixOS test suite exercises nested-container networking, including macvlan and extra-veth cases, and is the model for the VM integration test.
|
|
||||||
The two behaviors a VM cannot honestly reproduce — real 802.1Q against the physical switch and real ZFS identity-mapped writes on the pool — are verified manually on the target Host rather than in the test.
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- The `microvm` backend and any hard-isolation guest; the concept reserves the backend value but this work does not build it.
|
|
||||||
- Running more than one instance of a service on a Host; the Guest stays singleton and the operator will address multiplicity separately.
|
|
||||||
- Exotic OCI images that need their own nested init or unusual storage drivers, which are the future reason to reach for the microvm backend.
|
|
||||||
- The concrete homelab Host itself and its real values — its trunk interface name, VLAN ids, pool names, and per-Guest MAC and mount assignments — which are host-specific data supplied when a Host is added.
|
|
||||||
- Migrating specific services (the *arr stack, download clients, media servers) into Guests; this work delivers the concept and its foundations, not the service catalogue.
|
|
||||||
- Real-switch VLAN behavior and real ZFS identity-mapped writes, which are verified manually on the target Host.
|
|
||||||
|
|
||||||
## Further Notes
|
|
||||||
|
|
||||||
- The decision to introduce Guest as a third concept, backend-agnostic but container-only for now, sealed and singleton, is recorded in ADR 0006.
|
|
||||||
- The "no permission errors" result rests on the container backend's identity mapping being the privileged-container equivalent the operator already trusts from Proxmox; it is deliberately not the unprivileged idmap model, which is what made pool writes painful before.
|
|
||||||
- Pools are durable state imported by the Host, never recreated by a rebuild, so a service's data survives any rebuild or reimage.
|
|
||||||
- Podman is the default and recommended runtime inside a nesting Guest, matching what the operator already runs; a container-backend Guest is soft-isolated (shared kernel), the same isolation class as the Proxmox LXCs being replaced, so nothing is lost on that axis in the move.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
---
|
|
||||||
spec: guests
|
|
||||||
---
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
A new bundle Module, `modules.toolkit`, that turns on the baseline interactive environment — fish, tmux, nvim, git, and direnv — as one unit.
|
|
||||||
Enabling it on a Host brings up all five together, so any Host or Guest shell feels identical.
|
|
||||||
This is a deliberate bundle wanted as a unit, distinct from the grouping-directory aggregate enables ADR 0004 rejected.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] `modules.toolkit` declares an `enable` option following the Enable convention and lives at a path its file location mirrors, per the Namespace convention.
|
|
||||||
- [x] Enabling `modules.toolkit` turns on fish, tmux, nvim, git, and direnv as a group.
|
|
||||||
- [x] A Host that enables `modules.toolkit` still reads as a flat checklist — the bundle is one line, not a hidden group of five.
|
|
||||||
- [x] A Host enabling `modules.toolkit` builds via `nix flake check`, and the five underlying Modules are enabled (verifiable by `nix eval` of their `enable` values).
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
- `modules/toolkit.nix` follows the sanctioned aggregator pattern of `modules/desktop/desktop.nix`: an index node whose `enable` sets each member's `enable = lib.mkDefault true`, so a Host can still override any single piece while the one flag brings up the bundle.
|
|
||||||
- `neogaia` was converted as the demonstrating Host: its five individual `modules.{fish,git,direnv,tmux,nvim}.enable = true` lines collapse to one `modules.toolkit.enable = true`.
|
|
||||||
- The bundle also sets `modules.fish.defaultShell`, so fish is the login shell wherever the toolkit is enabled — part of making any Host or Guest shell feel identical. This is `mkDefault`, so a Host can still opt out. `neogaia`'s previously explicit `defaultShell` line is therefore dropped.
|
|
||||||
- Verified: `nix flake check` builds `checks.x86_64-linux.neogaia`, and `nix eval` of each of the five members' `enable` on neogaia returns `true`.
|
|
||||||
- The working tree also carries `.claude/CONTEXT.md`, ADR 0006, and the `guests` spec — planning artifacts for the wider `guests` spec, not this task. They are deliberately left out of this task's commit and staged separately by the broader work.
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
---
|
|
||||||
spec: guests
|
|
||||||
blocked-by: 0001-toolkit-bundle
|
|
||||||
---
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
The tracer bullet for the Guest concept: the thinnest complete path from discovery to a running nested container.
|
|
||||||
The Auto-loader gains a third kind, discovering every Guest under `guests/` the way it already discovers Modules and Hosts.
|
|
||||||
The shared base config splits into a host base (`system.nix`, carrying host-only machinery — bootloader, hardware profile, host identity, boot and garbage-collection timers) and a slim guest-base that every nested Guest stands on.
|
|
||||||
Both keep the primary user, home-manager, and the shared overlays; the guest-base additionally auto-enables the `toolkit` bundle and `modules.ssh`, and imports the full `modules/` tree so any Module is available inside a Guest.
|
|
||||||
A minimal sample Guest is Module-shaped: it declares its own `guests.<path>` namespace with an `enable` and a `backend` field (default `container`, `microvm` reserved but not built), and guards its body per the Enable convention.
|
|
||||||
Its body's payload — the one difference from a Module — realizes a nested container running the Guest's interior on the guest-base.
|
|
||||||
A Host enables the sample Guest exactly as it enables a Module, and the whole thing builds through the existing `nix flake check` seam.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] The Auto-loader discovers and wires every Guest under `guests/` as a third kind, with no manual `imports` edits, and a Guest's option path mirrors its `guests/` location per the Namespace convention (including the index-node and file-or-folder rules).
|
|
||||||
- [x] The shared base config is split into a host base and a slim guest-base; the existing Host still builds via `nix flake check` with its host-only machinery intact.
|
|
||||||
- [x] The guest-base includes the primary user, home-manager, and the shared overlays, auto-enables `toolkit` and `modules.ssh`, and imports the full `modules/` tree.
|
|
||||||
- [x] A sample Guest declares `guests.<path>.enable` plus a `backend` field defaulting to `container`, guards its body on `enable`, and realizes a nested container running its interior when a Host enables it.
|
|
||||||
- [x] The `microvm` backend value is accepted as reserved but unimplemented, failing clearly rather than silently building nothing.
|
|
||||||
- [x] A Host enabling the sample Guest builds via `nix flake check`, the guest is reachable from its Host by `machinectl` with no per-Guest configuration, and the baseline toolset and SSH access are present inside it.
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
- The base split is realized as three files, not two.
|
|
||||||
`base.nix` is the shared substrate both bases build on — the primary user, home-manager, the `unstable`/`stable` overlays, and flakes.
|
|
||||||
`system.nix` (the host base) imports it and adds the host-only machinery (bootloader limit, sops decryption and the password, the maintenance timers, the chaotic cache, console keymap).
|
|
||||||
`guest.nix` (the guest-base) imports it and adds the slim guest layer.
|
|
||||||
Factoring the common substrate out keeps "both include the primary user, home-manager, and the shared overlays" a single fact rather than a duplicated one.
|
|
||||||
|
|
||||||
- The guest-base imports `sops-nix` and `stylix` alongside the full `modules/` tree.
|
|
||||||
This is load-bearing, not incidental: the module system pushes an `mkIf` down to the leaves it guards, so an option path a module names must be *declared* even where its `enable` is off.
|
|
||||||
`modules/ssh.nix` names `sops.*` and the desktop modules name `stylix.*`, so those option namespaces have to exist for the tree to evaluate inside a guest that leaves them disabled.
|
|
||||||
|
|
||||||
- `modules/ssh.nix` gained a guest flavor.
|
|
||||||
`hostKeys.restore` (default on) gates restoring host keys from secrets, and both `hostKeys.sopsFile` and `userKey.sopsFile` are now nullable.
|
|
||||||
A guest sets `restore = false` and names no sops files, so its daemon self-generates a host key and it carries no age key — verified: the interior's `sops.secrets` is empty and `services.openssh.hostKeys` falls back to the generated defaults, while `neogaia` still restores its committed host keys with `openssh.hostKeys = [ ]`.
|
|
||||||
|
|
||||||
- The namespace mirroring is honored by author discipline, exactly as a Module's is: `my.guest { name = "sample"; }` names the option path, and `guests/sample.nix` places it.
|
|
||||||
The Auto-loader change is the same recursive `collectNixFiles`, so the index-node and file-or-folder rules a folder-shaped guest would use come for free from the loader that already serves Modules — no folder-shaped guest exists yet to exercise them.
|
|
||||||
|
|
||||||
- The guest gets `privateNetwork = true` by default, so its interior sshd never contends with the host's on the shared namespace.
|
|
||||||
It is `mkDefault`, so the networking foundation can later attach the guest to a VLAN bridge.
|
|
||||||
|
|
||||||
- `backend` is an `enum [ "container" "microvm" ]` defaulting to `container`.
|
|
||||||
`microvm` is built by nothing; choosing it trips a build-time assertion with a clear message rather than silently producing no container, per the acceptance criterion and ADR 0006's reserved-value decision.
|
|
||||||
|
|
||||||
- `neogaia` enables `guests.sample` to demonstrate the path end to end, the way it demonstrated `modules.toolkit`.
|
|
||||||
This means a rebuild starts a live nspawn container on the laptop; it is a minimal smoke test and can be turned off with one line.
|
|
||||||
|
|
||||||
- `nix flake check` passes and builds the nested `nixos-system-sample` interior in full.
|
|
||||||
A pre-existing nixvim warning about its own `nixpkgs.follows` now also prints for the guest's reused Neovim config; it is upstream noise, not a defect in this change.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
spec: guests
|
|
||||||
---
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
The host-level networking foundation, `modules.network`, that a Host declares once and every Guest attaches to.
|
|
||||||
A Host states its trunk interface and the set of VLANs to materialize, and the Module emits one bridge per tagged VLAN using systemd-networkd, named by the `br-vlan<id>` convention, and manages the Host's own management address.
|
|
||||||
This is a standalone host-level Module and does not yet wire any Guest to a bridge — that is the Guest networking placement slice.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] `modules.network` declares an `enable` option and its option path mirrors its file location per the Namespace convention.
|
|
||||||
- [x] A Host declares its trunk interface and its set of VLAN ids through the Module's options.
|
|
||||||
- [x] Enabling the Module emits exactly one systemd-networkd bridge per declared VLAN, each named `br-vlan<id>`, and manages the Host's own management address.
|
|
||||||
- [x] A Host enabling `modules.network` builds via `nix flake check`, and the emitted bridge names are verifiable by `nix eval`. Verified by temporarily enabling it on `neogaia`; the enablement is not committed (see notes).
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
Each VLAN materializes as three networkd entries: a `<trunk>.<id>` tagged sub-interface stacked on the trunk, a `br-vlan<id>` bridge, and a network enslaving the sub-interface to the bridge.
|
|
||||||
The trunk and every bridge set `RequiredForOnline = "no"`, so `systemd-networkd-wait-online` never blocks boot on a link with no carrier.
|
|
||||||
|
|
||||||
The management address takes a static CIDR, or DHCP when left null, on the management VLAN's bridge alone.
|
|
||||||
Two assertions guard it: the management VLAN must be one of the declared VLANs, and a declared management address must name a management VLAN, so an address can never be silently dropped for want of a bridge to carry it.
|
|
||||||
|
|
||||||
The Module owns its own NetworkManager `unmanaged` guard for the trunk, sub-interfaces, and bridges, so enabling it is self-sufficient on a host that also runs NetworkManager rather than pushing that wiring into every Host.
|
|
||||||
A `management.gateway` option was considered and dropped as speculative for this slice, since the foundation carries no other routing.
|
|
||||||
|
|
||||||
No host commits an enablement of this Module.
|
|
||||||
The repo's only host is `neogaia`, a wifi laptop on an access port, and enabling the Module there turns on `systemd-networkd` and pulls in `systemd-resolved`, which takes over the laptop's DNS.
|
|
||||||
That is an unwanted change to a daily machine that cannot present guests as L2 citizens anyway (wifi does not bridge), so the standing enablement waits for the first wired server host.
|
|
||||||
The build and bridge-name evaluation were verified by temporarily enabling the Module on `neogaia` (`nix flake check` passed, `nix eval` showed `br-vlan10`/`br-vlan20`), then reverting.
|
|
||||||
Both stay reproducible from the committed tree by enabling the Module ad hoc through `nixosConfigurations.neogaia.extendModules`, leaving the host file untouched.
|
|
||||||
|
|
||||||
No Guest is wired to a bridge — that is the guest networking placement slice.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
spec: guests
|
|
||||||
blocked-by: [0002-guest-walking-skeleton, 0003-network-vlan-foundation]
|
|
||||||
---
|
|
||||||
|
|
||||||
## What to build
|
|
||||||
|
|
||||||
The Host-side placement fields that make a Guest a first-class L2 citizen on a tagged VLAN, exactly as Proxmox did.
|
|
||||||
A Host sets `vlan`, and the Guest attaches to that Host's `br-vlan<id>` bridge by the naming convention, so the Guest states only which VLAN it lives on.
|
|
||||||
A Host may set `mac` to reuse an existing address so its router's DHCP reservations keep working; an unset `mac` derives a stable, readable address from the Guest's namespace path in a locally-administered range, surfaced via evaluation so the operator can add a reservation.
|
|
||||||
The MAC is pinned inside the guest through the guest's own systemd-networkd, which is the only way a nested-container MAC stays stable.
|
|
||||||
A Host may set `address` for a static IP; unset means DHCP, keeping IP management centralized at the router.
|
|
||||||
A build-time assertion ties the Guest's `vlan` to the set of VLANs its Host's `modules.network` declares, so a Guest naming an undeclared VLAN fails the Host build with a clear message rather than as a broken bridge at runtime.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- [x] A Host setting `guests.<path>.vlan` attaches the Guest to that Host's `br-vlan<id>` bridge by the naming convention.
|
|
||||||
- [x] Setting `mac` pins that exact address on the Guest via the guest's own systemd-networkd; leaving it unset derives a stable MAC from the Guest's namespace path in a locally-administered range, readable via `nix eval`.
|
|
||||||
- [x] Setting `address` gives the Guest a static IP on its VLAN; leaving it unset takes the address by DHCP.
|
|
||||||
- [x] A Guest whose `vlan` is not among its Host's declared VLANs fails `nix flake check` with a clear, actionable message naming the offending Guest and VLAN.
|
|
||||||
- [x] A Host with a correctly-placed networked Guest builds via `nix flake check`, and the Guest's resolved bridge attachment and derived MAC are verifiable by `nix eval`.
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
The `br-vlan<id>` naming was a local helper in `modules/network.nix` and is now a shared `bridgeName` in `lib.nix`, exported through `my` and consumed by both the network foundation and guest placement.
|
|
||||||
The convention has one source, so the bridge a guest attaches to can never drift from the bridge the host emits.
|
|
||||||
|
|
||||||
The interior networking is realized by a small module injected into the guest's container config only when `vlan` is set.
|
|
||||||
It enables the guest's own systemd-networkd on `eth0` — the name a nested container gives its bridged veth — pinning the placement MAC there and taking the static `address` or DHCP when it is unset.
|
|
||||||
Pinning the MAC through the guest's own networkd is the only way a nested-container MAC stays stable; the nspawn-assigned veth MAC is otherwise regenerated.
|
|
||||||
Enabling networkd default-enables `systemd-resolved`, so a DHCP guest also gets its resolver.
|
|
||||||
|
|
||||||
The derived MAC is the `mac` option's default, so an unset MAC reads back through `nix eval .#nixosConfigurations.<host>.config.guests.<path>.mac`.
|
|
||||||
The first octet is `02` (locally-administered, unicast) and the remaining five octets are a hash slice of the namespace path.
|
|
||||||
|
|
||||||
A static `address` sets only the on-VLAN IP, with no gateway or DNS.
|
|
||||||
This mirrors `modules.network`, which deliberately dropped a `management.gateway` as speculative for the foundation slice; off-VLAN routing for a statically-addressed guest is a later concern, and the centralized path stays DHCP.
|
|
||||||
|
|
||||||
The networked path is verified by `nix eval` against `neogaia` through `extendModules` rather than by committing an enablement, exactly as task 0003 verified `modules.network`.
|
|
||||||
`neogaia` is a wifi laptop that cannot bridge, and enabling networkd on it would take over its DNS, so its committed `guests.sample` placement leaves `vlan` unset.
|
|
||||||
Verified: with `vlan = 10` the guest resolves `hostBridge = br-vlan10` and the interior `eth0` networkd pins the derived MAC; an unset `address` yields `DHCP = "yes"` and a set one yields the static CIDR; and `vlan = 99` against declared `[10 20]` fails the build with the actionable message.
|
|
||||||
`nix flake check` passes with `guests.sample` building its interior in full.
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1 +1,8 @@
|
|||||||
/reference/
|
/reference/
|
||||||
|
/.direnv/
|
||||||
|
|
||||||
|
# BEGIN mkSkillsShellHook
|
||||||
|
# Generated by mkSkillsShellHook. Nix-delivered skill symlinks, kept out of git.
|
||||||
|
.claude/skills
|
||||||
|
.agents/skills/gitea-axi
|
||||||
|
# END mkSkillsShellHook
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ keys:
|
|||||||
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
||||||
# Generated on the machine it names.
|
# Generated on the machine it names.
|
||||||
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
||||||
|
- &pikachu age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
|
||||||
|
|
||||||
creation_rules:
|
creation_rules:
|
||||||
# Material belonging to one machine.
|
# Material belonging to one machine.
|
||||||
@@ -18,9 +19,16 @@ creation_rules:
|
|||||||
- *admin
|
- *admin
|
||||||
- *neogaia
|
- *neogaia
|
||||||
|
|
||||||
|
- path_regex: secrets/pikachu\.yaml$
|
||||||
|
key_groups:
|
||||||
|
- age:
|
||||||
|
- *admin
|
||||||
|
- *pikachu
|
||||||
|
|
||||||
# Material common to every machine, so it is stored once rather than per host.
|
# Material common to every machine, so it is stored once rather than per host.
|
||||||
- path_regex: secrets/shared\.yaml$
|
- path_regex: secrets/shared\.yaml$
|
||||||
key_groups:
|
key_groups:
|
||||||
- age:
|
- age:
|
||||||
- *admin
|
- *admin
|
||||||
- *neogaia
|
- *neogaia
|
||||||
|
- *pikachu
|
||||||
|
|||||||
94
AGENTS.md
Normal file
94
AGENTS.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# dotfiles-nixos
|
||||||
|
|
||||||
|
One flake that builds every machine the user owns.
|
||||||
|
The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overlays) lives in `~/Documents/ai-artifacts/projects/dotfiles/003-dotfiles-context.md`.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- Comments posted to Gitea (pull requests, issues, reviews) go out under the operator's account, so sign every one to make clear the author is the agent, not the operator.
|
||||||
|
End the comment with a `— Claude` sign-off.
|
||||||
|
(A dedicated bot account may replace this later.
|
||||||
|
Until then, the sign-off is the only marker.)
|
||||||
|
- Commit messages follow Conventional Commits, specified in `docs/conventional-commits.md`.
|
||||||
|
Scope is the module or host the change belongs to (`fish`, `nvim`, `neogaia`), omitted for repo-wide changes.
|
||||||
|
Keep messages free of Gitea-specific references: this repository is mirrored to GitHub, where issue and pull-request numbers resolve to unrelated things.
|
||||||
|
- When a graphical application is added, give it a `window-rewrite` icon mapping in `modules/desktop/waybar.nix`.
|
||||||
|
Without one its windows fall back to the generic default glyph on the workspace indicator instead of showing a recognisable per-application icon.
|
||||||
|
Match on the window class, which `hyprctl clients -j | jq -r '.[].class' | sort -u` lists for the running session.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Subagent completion delivery is non-blocking through immediate spawn, milestone notifications, retained terminal entries, and `subagent_list` or `subagent_result` retrieval.
|
||||||
|
`subagent_wait` intentionally blocks the parent tool call until its condition or timeout, so do not use it merely to keep background work alive during an interactive workflow.
|
||||||
|
- Nixvim's flake input following the root nixpkgs source does not make its Home Manager module reuse the host's `pkgs` instance.
|
||||||
|
Keep `programs.nixvim.nixpkgs.useGlobalPackages = true` so Nixvim uses the shared package set without warning that its source default was affected.
|
||||||
|
- This host has no `python` or `python3` command on its ordinary `PATH`.
|
||||||
|
For ad hoc Python, use Nix explicitly, such as `nix shell nixpkgs#python3 -c python3 <script>`.
|
||||||
|
- ADR bodies are immutable records of decisions as they were made, while frontmatter is mutable.
|
||||||
|
When a decision changes or its premise proves wrong, preserve the original body, update its status, and add a new ADR that supersedes it.
|
||||||
|
Filename migrations preserve references in immutable bodies through frontmatter aliases rather than rewriting those bodies.
|
||||||
|
- This repo pins no Nix formatter, and its committed `.nix` files are not clean under current `nixfmt-rfc-style`.
|
||||||
|
Running `nixfmt` across a file reflows untouched code (for example `lib.nix`'s `deriveMac` list and multi-line assertion messages) and injects churn unrelated to the change.
|
||||||
|
Format only the lines being written or changed, matching the surrounding style by hand.
|
||||||
|
- This repo is developed on `neogaia`, which now runs the NixOS it builds.
|
||||||
|
Flakes and the chaotic substituter come from this flake's own `nix.settings`, so no `NIX_CONFIG` export or per-command `--extra-experimental-features` is needed, and building a toplevel with `boot.kernelPackages = linuxPackages_cachyos` fetches the kernel from `nyx-cache` rather than compiling it.
|
||||||
|
Both were true only while the machine still ran CachyOS against a distro Nix daemon.
|
||||||
|
- Git identity is declared in the flake by `modules/git.nix`, which writes `alexion <contact@alexion.dev>` — the identity all history uses — on any host enabling `modules.git`.
|
||||||
|
Every new host has to enable it, so that a host reads as a full checklist of what it carries.
|
||||||
|
It is deployed on `neogaia` and verified: a commit in a repository outside this checkout is authored `alexion <contact@alexion.dev>` with no override.
|
||||||
|
Verify it that way rather than from this checkout, whose `.git/config` carries the same identity and would mask a broken module.
|
||||||
|
`~/.gitconfig` (a second global file that outranks the flake-managed `~/.config/git/config`) currently holds only a `tea` credential helper and no `user.*`, so it does not shadow the identity, but it is undeclared and will not survive a reimage.
|
||||||
|
- The primary build/verify seam for any Host is `nix flake check`, which builds `checks.x86_64-linux.<host>` (the system toplevel).
|
||||||
|
Cheap targeted checks use `nix eval .#nixosConfigurations.<host>.config...`.
|
||||||
|
- chaotic-nyx must **not** follow our `nixpkgs`, and its packages are built against chaotic's own pinned nixpkgs (its overlay defaults to `onTopOf = "flake-nixpkgs"`, the cache-friendly path).
|
||||||
|
That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source.
|
||||||
|
The tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays.
|
||||||
|
- The remote is self-hosted Gitea (`git.alexion.dev`), and the forge CLI is `gitea-axi` rather than `tea`.
|
||||||
|
`gitea-axi` resolves the repository from the `origin` remote and discovers credentials from a `tea` login whose host matches the remote, so both are implicit inside a checkout.
|
||||||
|
It is installed on `neogaia` by `modules.agents.tools.gitea-axi`, and verified: `gitea-axi` run from this checkout renders the `alexion/dotfiles` dashboard authenticated, so the claude-code `SessionStart` hook that runs it now resolves to a real binary rather than a missing one.
|
||||||
|
The package wraps the binary so `git` and `tea` are reachable without being on `PATH`, while still preferring the operator's own where present.
|
||||||
|
Credentials: `~/.config/tea/config.yml` holds a token-bearing login named `alexion`, which `gitea-axi` uses and which also opens pull requests directly with `nix run nixpkgs#tea -- pr create --login alexion --repo alexion/dotfiles --base main --head <branch> ...`.
|
||||||
|
The `--repo` flag is required on that path, since `tea` resolves `origin` only for a login whose SSH host matches.
|
||||||
|
The same token reads PR discussion, which `tea` itself does poorly: `tea pr <n> --comments` prints only the body, and `-f comments` returns no comments field at all.
|
||||||
|
Use the API instead, taking the token from `.logins[] | select(.name=="alexion") | .token`.
|
||||||
|
Review comments are **not** at `/issues/<n>/comments` — that endpoint holds only top-level discussion and is usually empty.
|
||||||
|
Inline comments need two calls: `/pulls/<n>/reviews` for the review ids, then `/pulls/<n>/reviews/<id>/comments` for the bodies, whose `path` and `diff_hunk` fields say what each one is attached to.
|
||||||
|
A review row with an empty `body` is the normal shape when the operator left only inline comments.
|
||||||
|
- `~/.claude/skills` and `~/.pi/agent/skills` are home-manager-generated (`recursive = true`), so editing a skill in place fails and a new file created there silently escapes the repo.
|
||||||
|
Shared global skills come from the `skills` flake through `modules/agents/skills.nix`, applied by a rebuild.
|
||||||
|
Claude-specific legacy skills, when kept, live under `modules/agents/claude-code/skills/<name>/`.
|
||||||
|
- Pi skill discovery honors `.gitignore`, `.ignore`, and `.fdignore` inside scanned skill directories.
|
||||||
|
A generated `.agents/skills/.gitignore` entry that ignores a symlinked skill also prevents Pi from loading that skill, even when `.agents/skills/<name>/SKILL.md` exists and the symlink target is valid.
|
||||||
|
- nixpkgs `vimPlugins.nord-nvim` is `shaunsingh/nord.nvim` (no `require("nord").setup()`).
|
||||||
|
The config wants `gbprod/nord.nvim`, which is packaged as `vimPlugins.gbprod-nord`.
|
||||||
|
- `nixos-generate-config --show-hardware-config` needs root on this machine even just to print: unprivileged it dies at `Failed to retrieve subvolume info for /`, because the root filesystem is btrfs.
|
||||||
|
- This repo's claude-code module sets sudo's credential cache to per-user (`timestamp_type=global`, 60-minute window), so an authentication made in one real terminal counts for the agent's commands.
|
||||||
|
A `PreToolUse` hook refuses privileged commands while the cache is cold, so a cold cache announces itself instead of stalling.
|
||||||
|
A privileged-command failure *without* that message is the sandbox, not the cache.
|
||||||
|
- Host GPUs: `neogaia` is Intel, `zeus` (the desktop) is **AMD**, and `raichu` (a headless server) is the only Nvidia machine.
|
||||||
|
The corrected fact also lives in artifact `006-dotfiles-hyprland-compositor-adr.md`.
|
||||||
|
- This repo's `programs.firefox` `search` (with `force = true`) writes `search.json.mozlz4`.
|
||||||
|
Omission alone does not prune a built-in engine, since Firefox reconciles its app-provided engines back in, so remove one by listing it with `<engine>.metaData.hidden = true`.
|
||||||
|
Engines are referenced by their current id, so the default is `default = "ddg"`, not `"DuckDuckGo"`.
|
||||||
|
Decode the built file with `mozlz4a -d <search.json.mozlz4>` to check the result.
|
||||||
|
- Any non-empty Home Manager Firefox `profiles.<name>.extensions.settings.<id>.settings` causes Home Manager to set `extensions.webextensions.ExtensionStorageIDB.enabled = false` globally for that profile.
|
||||||
|
This repo's Stylix Firefox `colorTheme` settings trigger it, so every extension in the profile uses the legacy extension-storage backend regardless of how it is installed.
|
||||||
|
- `home.sessionVariables` do **not** reach the Hyprland session, since UWSM does not source `hm-session-vars.sh`.
|
||||||
|
The cursor is therefore set through Hyprland's own `env = KEY,VALUE` in `modules/desktop/hyprland/hyprland.nix`, sourced from `config.stylix.cursor`.
|
||||||
|
Bibata ships XCursor format only (no `hyprcursor/` dir), rendered through Hyprland's XCursor fallback, so `XCURSOR_*` and `HYPRCURSOR_*` naming the same theme are both safe.
|
||||||
|
- `neogaia`, the repo's only host, is a wifi laptop with a btrfs root and no ZFS pools, so it cannot honestly carry `modules.network`, `modules.zfs`, or a networked/pool-mounted guest.
|
||||||
|
Enabling networkd takes over its DNS, its CachyOS `zfs-kernel` build is marked broken, and it has no bridge or pool to attach to.
|
||||||
|
Verify these against it ad hoc through `nixosConfigurations.neogaia.extendModules` (forcing a ZFS-capable `boot.kernelPackages` for the zfs case) plus `nix eval` of the derived values, never by committing the enablement.
|
||||||
|
A committed guest therefore leaves `vlan`, `mounts`, and `secrets` unset, and the standing enablement waits for the first wired server host with real storage.
|
||||||
|
- Herdr key names for shifted punctuation are not interchangeable with the physical base key plus `shift`.
|
||||||
|
The tab rename binding must use the produced literal, such as `prefix+<`, rather than `prefix+shift+comma`.
|
||||||
|
- Flake-managed Pi extension, prompt, and skill directories may still be written directly for throwaway development or local experiments.
|
||||||
|
The risk is that a later Home Manager activation can overwrite or hide those unmanaged files, so finished work must be promoted into the dotfiles module before it counts as deployed.
|
||||||
|
- Pi's tool discovery checks `~/.pi/agent/bin` before `PATH`, and downloaded generic Linux binaries there can be unusable on NixOS with the stub-ld error.
|
||||||
|
This flake patches Pi to validate local tool binaries before selecting them, so it falls back to usable `fd`/`rg` from `PATH` instead.
|
||||||
|
Stale unpatched launchers are the remaining failure mode for broken `@` autocomplete.
|
||||||
|
- Nix flake evaluation ignores untracked files in this checkout.
|
||||||
|
Keep a new auto-loaded module staged or committed until it is removed, otherwise `nix flake check` and `nixos-rebuild --flake` evaluate without it and report its options as missing.
|
||||||
|
- The current Steam desktop client is an XWayland application.
|
||||||
|
Its CEF windows do not support Ozone and Steam composites them into an SDL surface with X11 extensions, so SDL Wayland selectors do not make the visible client native Wayland.
|
||||||
|
Keep fractional scaling sharp with Hyprland's `xwayland.force_zero_scaling` and Steam's own `STEAM_FORCE_DESKTOPUI_SCALING` instead.
|
||||||
64
CLAUDE.md
64
CLAUDE.md
@@ -1,61 +1,5 @@
|
|||||||
# dotfiles-nixos
|
# Claude Code compatibility
|
||||||
|
|
||||||
One flake that builds every machine the user owns.
|
You MUST read and follow [`AGENTS.md`](AGENTS.md) before doing any work in this repository.
|
||||||
The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overlays) lives in `.claude/CONTEXT.md`.
|
`AGENTS.md` is the canonical project instruction file.
|
||||||
|
This file exists only so Claude Code discovers that canonical instruction file.
|
||||||
## Conventions
|
|
||||||
|
|
||||||
- Comments posted to Gitea (pull requests, issues, reviews) go out under the operator's account, so sign every one to make clear the author is the agent, not the operator.
|
|
||||||
End the comment with a `— Claude` sign-off.
|
|
||||||
(A dedicated bot account may replace this later.
|
|
||||||
Until then, the sign-off is the only marker.)
|
|
||||||
- Commit messages follow Conventional Commits, specified in `docs/conventional-commits.md`.
|
|
||||||
Scope is the module or host the change belongs to (`fish`, `nvim`, `neogaia`), omitted for repo-wide changes.
|
|
||||||
Keep messages free of Gitea-specific references: this repository is mirrored to GitHub, where issue and pull-request numbers resolve to unrelated things.
|
|
||||||
- When a graphical application is added, give it a `window-rewrite` icon mapping in `modules/desktop/waybar.nix`.
|
|
||||||
Without one its windows fall back to the generic default glyph on the workspace indicator instead of showing a recognisable per-application icon.
|
|
||||||
Match on the window class, which `hyprctl clients -j | jq -r '.[].class' | sort -u` lists for the running session.
|
|
||||||
|
|
||||||
## Gotchas
|
|
||||||
|
|
||||||
- This repo is developed on `neogaia`, which now runs the NixOS it builds.
|
|
||||||
Flakes and the chaotic substituter come from this flake's own `nix.settings`, so no `NIX_CONFIG` export or per-command `--extra-experimental-features` is needed, and building a toplevel with `boot.kernelPackages = linuxPackages_cachyos` fetches the kernel from `nyx-cache` rather than compiling it.
|
|
||||||
Both were true only while the machine still ran CachyOS against a distro Nix daemon.
|
|
||||||
- Git identity is declared in the flake by `modules/git.nix`, which writes `alexion <contact@alexion.dev>` — the identity all history uses — on any host enabling `modules.git`.
|
|
||||||
Every new host has to enable it, so that a host reads as a full checklist of what it carries.
|
|
||||||
It is deployed on `neogaia` and verified: a commit in a repository outside this checkout is authored `alexion <contact@alexion.dev>` with no override.
|
|
||||||
Verify it that way rather than from this checkout, whose `.git/config` carries the same identity and would mask a broken module.
|
|
||||||
`~/.gitconfig` (a second global file that outranks the flake-managed `~/.config/git/config`) currently holds only a `tea` credential helper and no `user.*`, so it does not shadow the identity, but it is undeclared and will not survive a reimage.
|
|
||||||
- The primary build/verify seam for any Host is `nix flake check`, which builds `checks.x86_64-linux.<host>` (the system toplevel).
|
|
||||||
Cheap targeted checks use `nix eval .#nixosConfigurations.<host>.config...`.
|
|
||||||
- chaotic-nyx must **not** follow our `nixpkgs`, and its packages are built against chaotic's own pinned nixpkgs (its overlay defaults to `onTopOf = "flake-nixpkgs"`, the cache-friendly path).
|
|
||||||
That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source.
|
|
||||||
The tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays.
|
|
||||||
- The remote is self-hosted Gitea (`git.alexion.dev`), and the forge CLI is `gitea-axi` rather than `tea`.
|
|
||||||
`gitea-axi` resolves the repository from the `origin` remote and discovers credentials from a `tea` login whose host matches the remote, so both are implicit inside a checkout.
|
|
||||||
It is installed on `neogaia` by `modules.agents.tools.gitea-axi`, and verified: `gitea-axi` run from this checkout renders the `alexion/dotfiles` dashboard authenticated, so the claude-code `SessionStart` hook that runs it now resolves to a real binary rather than a missing one.
|
|
||||||
The package wraps the binary so `git` and `tea` are reachable without being on `PATH`, while still preferring the operator's own where present.
|
|
||||||
Credentials: `~/.config/tea/config.yml` holds a token-bearing login named `alexion`, which `gitea-axi` uses and which also opens pull requests directly with `nix run nixpkgs#tea -- pr create --login alexion --repo alexion/dotfiles --base main --head <branch> ...`.
|
|
||||||
The `--repo` flag is required on that path, since `tea` resolves `origin` only for a login whose SSH host matches.
|
|
||||||
The same token reads PR discussion, which `tea` itself does poorly: `tea pr <n> --comments` prints only the body, and `-f comments` returns no comments field at all.
|
|
||||||
Use the API instead, taking the token from `.logins[] | select(.name=="alexion") | .token`.
|
|
||||||
Review comments are **not** at `/issues/<n>/comments` — that endpoint holds only top-level discussion and is usually empty.
|
|
||||||
Inline comments need two calls: `/pulls/<n>/reviews` for the review ids, then `/pulls/<n>/reviews/<id>/comments` for the bodies, whose `path` and `diff_hunk` fields say what each one is attached to.
|
|
||||||
A review row with an empty `body` is the normal shape when the operator left only inline comments.
|
|
||||||
- `~/.claude/skills` is home-manager-generated (`recursive = true`), so editing a skill in place fails and a new file created there silently escapes the repo.
|
|
||||||
Its real source is `modules/agents/claude-code/skills/<name>/`, applied by a rebuild.
|
|
||||||
- nixpkgs `vimPlugins.nord-nvim` is `shaunsingh/nord.nvim` (no `require("nord").setup()`).
|
|
||||||
The config wants `gbprod/nord.nvim`, which is packaged as `vimPlugins.gbprod-nord`.
|
|
||||||
- `nixos-generate-config --show-hardware-config` needs root on this machine even just to print: unprivileged it dies at `Failed to retrieve subvolume info for /`, because the root filesystem is btrfs.
|
|
||||||
- This repo's claude-code module sets sudo's credential cache to per-user (`timestamp_type=global`, 60-minute window), so an authentication made in one real terminal counts for the agent's commands.
|
|
||||||
A `PreToolUse` hook refuses privileged commands while the cache is cold, so a cold cache announces itself instead of stalling.
|
|
||||||
A privileged-command failure *without* that message is the sandbox, not the cache.
|
|
||||||
- Host GPUs: `neogaia` is Intel, `zeus` (the desktop) is **AMD**, and `raichu` (a headless server) is the only Nvidia machine.
|
|
||||||
The corrected fact also lives in ADR 0003.
|
|
||||||
- This repo's `programs.firefox` `search` (with `force = true`) writes `search.json.mozlz4`.
|
|
||||||
Omission alone does not prune a built-in engine, since Firefox reconciles its app-provided engines back in, so remove one by listing it with `<engine>.metaData.hidden = true`.
|
|
||||||
Engines are referenced by their current id, so the default is `default = "ddg"`, not `"DuckDuckGo"`.
|
|
||||||
Decode the built file with `mozlz4a -d <search.json.mozlz4>` to check the result.
|
|
||||||
- `home.sessionVariables` do **not** reach the Hyprland session, since UWSM does not source `hm-session-vars.sh`.
|
|
||||||
The cursor is therefore set through Hyprland's own `env = KEY,VALUE` in `modules/desktop/hyprland/hyprland.nix`, sourced from `config.stylix.cursor`.
|
|
||||||
Bibata ships XCursor format only (no `hyprcursor/` dir), rendered through Hyprland's XCursor fallback, so `XCURSOR_*` and `HYPRCURSOR_*` naming the same theme are both safe.
|
|
||||||
|
|||||||
11
base.nix
11
base.nix
@@ -59,9 +59,18 @@ in
|
|||||||
users.users.${user.name} = {
|
users.users.${user.name} = {
|
||||||
isNormalUser = true;
|
isNormalUser = true;
|
||||||
description = user.description;
|
description = user.description;
|
||||||
extraGroups = [ "wheel" ];
|
extraGroups = [
|
||||||
|
"wheel"
|
||||||
|
"storage"
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# The shared write group.
|
||||||
|
# Its gid is fixed, so a host and every guest carry the same number.
|
||||||
|
# An identity-mapped container write then lands on the pool as this group, sparing every service the permission juggling.
|
||||||
|
# 10000 clears the system-group ids assigned automatically and leaves headroom above the primary user, so nothing else claims it.
|
||||||
|
users.groups.storage.gid = 10000;
|
||||||
|
|
||||||
# home-manager as a NixOS module: one build produces the system and user
|
# home-manager as a NixOS module: one build produces the system and user
|
||||||
# environment together, sharing the system's pkgs and installing user
|
# environment together, sharing the system's pkgs and installing user
|
||||||
# packages into the system profile.
|
# packages into the system profile.
|
||||||
|
|||||||
140
flake.lock
generated
140
flake.lock
generated
@@ -75,11 +75,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784318604,
|
"lastModified": 1785327209,
|
||||||
"narHash": "sha256-P/N5ZbGWITiTfmiWpE/1uyXdOCagpgw/YAZLZJSzx/I=",
|
"narHash": "sha256-heXGjUBU1UsTHFzedDzYct9Cblr6FGzQcYyjCykywh8=",
|
||||||
"owner": "chaotic-cx",
|
"owner": "chaotic-cx",
|
||||||
"repo": "nyx",
|
"repo": "nyx",
|
||||||
"rev": "21a8ef816f34558a438d778057a8809322ea2415",
|
"rev": "90cfa9864fa08c923dddeca965103ad44663dd64",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -109,6 +109,28 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"firefox-addons": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"dir": "pkgs/firefox-addons",
|
||||||
|
"lastModified": 1785384175,
|
||||||
|
"narHash": "sha256-sWSJPXpQwKJstL4rdhpAQYCYlHK5wOkEHR8/lNHBVb4=",
|
||||||
|
"owner": "rycee",
|
||||||
|
"repo": "nur-expressions",
|
||||||
|
"rev": "db607f3d0afe811bcb3b16266f28b2fc5af4e74f",
|
||||||
|
"type": "gitlab"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"dir": "pkgs/firefox-addons",
|
||||||
|
"owner": "rycee",
|
||||||
|
"repo": "nur-expressions",
|
||||||
|
"type": "gitlab"
|
||||||
|
}
|
||||||
|
},
|
||||||
"firefox-gnome-theme": {
|
"firefox-gnome-theme": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
@@ -223,11 +245,33 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784592615,
|
"lastModified": 1785340481,
|
||||||
"narHash": "sha256-AH96vm0yYyS9sk35GnagZoWww8s8NHWYuyZJpSStlMM=",
|
"narHash": "sha256-GSxdQ7w8yYfZnfkXUuZ2fYIKibe9ZU8xDGyqpeTb2tE=",
|
||||||
"ref": "refs/heads/main",
|
"ref": "refs/heads/main",
|
||||||
"rev": "1468003f5b63f49fcd3cd456c25a8ad4f25716cc",
|
"rev": "627fc9a32bb80d97923540fc0d3e9661961462ba",
|
||||||
"revCount": 82,
|
"revCount": 83,
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.alexion.dev/alexion/gitea-axi"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.alexion.dev/alexion/gitea-axi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitea-axi_2": {
|
||||||
|
"inputs": {
|
||||||
|
"home-manager": "home-manager_4",
|
||||||
|
"nixpkgs": [
|
||||||
|
"skills",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1785340481,
|
||||||
|
"narHash": "sha256-GSxdQ7w8yYfZnfkXUuZ2fYIKibe9ZU8xDGyqpeTb2tE=",
|
||||||
|
"ref": "refs/heads/main",
|
||||||
|
"rev": "627fc9a32bb80d97923540fc0d3e9661961462ba",
|
||||||
|
"revCount": 83,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.alexion.dev/alexion/gitea-axi"
|
"url": "https://git.alexion.dev/alexion/gitea-axi"
|
||||||
},
|
},
|
||||||
@@ -263,11 +307,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784129366,
|
"lastModified": 1785288465,
|
||||||
"narHash": "sha256-N5JiyICSeQF14x+OQebNyPpYowOT9Rs1iKyeCylSzOA=",
|
"narHash": "sha256-nCkxaGRtyNheNTxoc527gjOG0BN2zovsWDQVBeKDMW8=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "165228b0efefc3e635e5174020c40ea64271dc25",
|
"rev": "36662afed2fa1c9b69bdd03edb92ad572202ca20",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -304,11 +348,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784351324,
|
"lastModified": 1785306346,
|
||||||
"narHash": "sha256-By+kuRJZRqs2TuXgtR8vJ8cTKWXw33YG/Yollu5cO1U=",
|
"narHash": "sha256-DScBkW0fOgpGPK2trNoX3ryLTlaC14+gglFo/BhGJ4g=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "460108009ca1ff69ca2ff19079ca2c838d6e3080",
|
"rev": "e705714e918c3b11affcdd15db2cbe3a070420a0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -318,6 +362,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"home-manager_4": {
|
"home-manager_4": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"skills",
|
||||||
|
"gitea-axi",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784588016,
|
||||||
|
"narHash": "sha256-ouZe80aWEhMLVMkqICFDN+JUw+0FJtCr/bh+hHtRtMg=",
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"rev": "deeb6b7eb7e0c44ae1819c051ce175bd92a85100",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-community",
|
||||||
|
"repo": "home-manager",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"home-manager_5": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"skills",
|
"skills",
|
||||||
@@ -345,11 +411,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784310968,
|
"lastModified": 1785232496,
|
||||||
"narHash": "sha256-rkSPTePrKqs4dg+i7ZFCq93+HrClac6oSwXX927SVjA=",
|
"narHash": "sha256-65EQYIRRpTdpH8lUiB6Mvo5uBkG60aBIzAJuALfx+O0=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixos-hardware",
|
"repo": "nixos-hardware",
|
||||||
"rev": "779c32a00155994c86cde8213a8dd4df139d4355",
|
"rev": "2e790b0a6be8ec2b76174ac0931b8ff11919ec98",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -360,11 +426,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784120854,
|
"lastModified": 1785090369,
|
||||||
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
|
"narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
|
"rev": "624af665418d3c65d544145b4d34ad696439570e",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -376,11 +442,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784280462,
|
"lastModified": 1785133411,
|
||||||
"narHash": "sha256-DtoqIqM7VkR6NxAkcLpMwmi02USwWb3JdmNGLyhthc0=",
|
"narHash": "sha256-Yjv0WEg39KRYS0rBdTbu6Fc/or/ihAKk13W9sQ6VWd0=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "293d6abedf0478e681a4dfcfcb35b30fc796a32f",
|
"rev": "2f5a153c270b70cb0f8c11f46d96d6d3bc39f4e3",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -392,11 +458,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-unstable": {
|
"nixpkgs-unstable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784347607,
|
"lastModified": 1785301185,
|
||||||
"narHash": "sha256-VI5cdo27nEZ3m1SlgB8RvBbrqFUO2/dUgrrLWe407oA=",
|
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "31cd72fdba8fa052e437ce7e6879c4fe62def10f",
|
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -408,11 +474,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784120854,
|
"lastModified": 1785318670,
|
||||||
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
|
"narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
|
"rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -431,11 +497,11 @@
|
|||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784057377,
|
"lastModified": 1785364321,
|
||||||
"narHash": "sha256-yycNej5//EsRbV10moBoh+/63vXEwZD1ZFEiRm6C9rQ=",
|
"narHash": "sha256-BLuHl+nZKb+FDq3GAM6L+UBEiyVepXANA31fT1F56pw=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixvim",
|
"repo": "nixvim",
|
||||||
"rev": "07180a087e4a00720dc0731cbcd8dec796974381",
|
"rev": "acd69cc15d57004e8cb4495034320263a3d362ea",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -473,6 +539,7 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"chaotic": "chaotic",
|
"chaotic": "chaotic",
|
||||||
"disko": "disko",
|
"disko": "disko",
|
||||||
|
"firefox-addons": "firefox-addons",
|
||||||
"gitea-axi": "gitea-axi",
|
"gitea-axi": "gitea-axi",
|
||||||
"home-manager": "home-manager_3",
|
"home-manager": "home-manager_3",
|
||||||
"nixos-hardware": "nixos-hardware",
|
"nixos-hardware": "nixos-hardware",
|
||||||
@@ -488,17 +555,18 @@
|
|||||||
"skills": {
|
"skills": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"home-manager": "home-manager_4",
|
"gitea-axi": "gitea-axi_2",
|
||||||
|
"home-manager": "home-manager_5",
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785010240,
|
"lastModified": 1785695024,
|
||||||
"narHash": "sha256-s/3PMHATZlUzZ9p0MYZF1hhZF8AfrKVxhYFlvp9tTog=",
|
"narHash": "sha256-DLLk6X5zu3cRT50p18uHVdwjGVtiS0t/661M34q02zU=",
|
||||||
"ref": "refs/heads/main",
|
"ref": "refs/heads/main",
|
||||||
"rev": "ea8e2c50bc1c1a0ab8a31f5eb807ef601d531ad6",
|
"rev": "9b2a6bcd583d7d6bf7e5377c3632f601692df209",
|
||||||
"revCount": 18,
|
"revCount": 54,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.alexion.dev/alexion/skills"
|
"url": "https://git.alexion.dev/alexion/skills"
|
||||||
},
|
},
|
||||||
|
|||||||
25
flake.nix
25
flake.nix
@@ -16,6 +16,12 @@
|
|||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Signed AMO extensions, pinned by version and hash.
|
||||||
|
firefox-addons = {
|
||||||
|
url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
# Follows our nixpkgs so its plugins build against the same package set.
|
# Follows our nixpkgs so its plugins build against the same package set.
|
||||||
nixvim = {
|
nixvim = {
|
||||||
url = "github:nix-community/nixvim";
|
url = "github:nix-community/nixvim";
|
||||||
@@ -79,9 +85,26 @@
|
|||||||
# Every host under hosts/ is discovered and built.
|
# Every host under hosts/ is discovered and built.
|
||||||
nixosConfigurations = my.mkHosts (self + "/hosts");
|
nixosConfigurations = my.mkHosts (self + "/hosts");
|
||||||
|
|
||||||
|
# A project shell for agent-local resources that should travel with this
|
||||||
|
# checkout rather than the operator's global profile.
|
||||||
|
devShells.x86_64-linux.default =
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||||
|
in
|
||||||
|
pkgs.mkShell {
|
||||||
|
packages = [ inputs.gitea-axi.packages.x86_64-linux.gitea-axi ];
|
||||||
|
shellHook = inputs.skills.lib.mkSkillsShellHook [
|
||||||
|
inputs.gitea-axi.packages.x86_64-linux.gitea-axi-skill
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
# `nix flake check` builds each host's toplevel.
|
# `nix flake check` builds each host's toplevel.
|
||||||
checks.x86_64-linux = lib.mapAttrs (
|
checks.x86_64-linux = lib.mapAttrs (
|
||||||
_name: host: host.config.system.build.toplevel
|
name: host:
|
||||||
|
if host.config.warnings == [] then
|
||||||
|
host.config.system.build.toplevel
|
||||||
|
else
|
||||||
|
throw "Host ${name} has evaluation warnings:\n${lib.concatStringsSep "\n" host.config.warnings}"
|
||||||
) self.nixosConfigurations;
|
) self.nixosConfigurations;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
11
guests/nesting-sample.nix
Normal file
11
guests/nesting-sample.nix
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
args@{ my, ... }:
|
||||||
|
# A sample guest whose interior runs an OCI container on Podman.
|
||||||
|
# The image is pulled at runtime, so the guest builds with no build-time fetch.
|
||||||
|
my.guest {
|
||||||
|
name = "nesting-sample";
|
||||||
|
interior = {
|
||||||
|
virtualisation.oci-containers.containers.hello = {
|
||||||
|
image = "docker.io/library/hello-world";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
} args
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
config,
|
|
||||||
inputs,
|
inputs,
|
||||||
pkgs,
|
pkgs,
|
||||||
...
|
...
|
||||||
@@ -39,21 +38,38 @@
|
|||||||
modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
|
modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
|
||||||
modules.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml;
|
modules.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml;
|
||||||
|
|
||||||
# A machine the operator works from, so it admits the workstation keys alone.
|
|
||||||
modules.ssh.authorizedKeys = config.modules.ssh.workstationKeys;
|
|
||||||
|
|
||||||
modules.toolkit.enable = true;
|
modules.toolkit.enable = true;
|
||||||
|
|
||||||
# The walking-skeleton guest, enabled like any module: proves the guest path
|
# The walking-skeleton guest, enabled like any module: proves the guest path
|
||||||
# end to end through this host's `nix flake check`.
|
# end to end through this host's `nix flake check`.
|
||||||
|
# Modest caps keep the skeleton guest from starving the laptop.
|
||||||
guests.sample.enable = true;
|
guests.sample.enable = true;
|
||||||
|
guests.sample.limits = {
|
||||||
|
memory = "1G";
|
||||||
|
cpu = "100%";
|
||||||
|
tasksMax = 512;
|
||||||
|
};
|
||||||
|
|
||||||
|
# The nesting guest, run with `nesting` on: proves an interior OCI container
|
||||||
|
# on Podman builds end to end through this host's `nix flake check`.
|
||||||
|
guests.nesting-sample.enable = true;
|
||||||
|
guests.nesting-sample.nesting = true;
|
||||||
|
guests.nesting-sample.limits = {
|
||||||
|
memory = "1G";
|
||||||
|
cpu = "100%";
|
||||||
|
tasksMax = 512;
|
||||||
|
};
|
||||||
|
|
||||||
modules.agents.claude-code.enable = true;
|
modules.agents.claude-code.enable = true;
|
||||||
|
modules.agents.herdr.enable = true;
|
||||||
modules.agents.tools.gitea-axi.enable = true;
|
modules.agents.tools.gitea-axi.enable = true;
|
||||||
modules.agents.pi.enable = true;
|
modules.agents.pi.enable = true;
|
||||||
|
modules.agents.pi.subagents.maxConcurrent = 8;
|
||||||
|
modules.agents.pi.subagents.recentTerminalTtlMs = 15 * 60 * 1000;
|
||||||
|
|
||||||
modules.desktop.enable = true;
|
modules.desktop.enable = true;
|
||||||
modules.desktop.obsidian.enable = true;
|
modules.desktop.obsidian.enable = true;
|
||||||
|
modules.desktop.steam.enable = true;
|
||||||
|
|
||||||
time.timeZone = "America/New_York";
|
time.timeZone = "America/New_York";
|
||||||
i18n.defaultLocale = "en_GB.UTF-8";
|
i18n.defaultLocale = "en_GB.UTF-8";
|
||||||
|
|||||||
54
hosts/pikachu/default.nix
Normal file
54
hosts/pikachu/default.nix
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{ pkgs, ... }:
|
||||||
|
# pikachu — AZW ME Pro server.
|
||||||
|
# Disk layout is in ./disk.nix.
|
||||||
|
# `fileSystems` for the root disk are derived from it.
|
||||||
|
{
|
||||||
|
imports = [
|
||||||
|
./hardware-configuration.nix
|
||||||
|
./disk.nix
|
||||||
|
];
|
||||||
|
|
||||||
|
system.stateVersion = "26.05";
|
||||||
|
|
||||||
|
boot.loader.systemd-boot.enable = true;
|
||||||
|
boot.loader.efi.canTouchEfiVariables = true;
|
||||||
|
|
||||||
|
hardware.cpu.intel.updateMicrocode = true;
|
||||||
|
hardware.enableRedistributableFirmware = true;
|
||||||
|
|
||||||
|
zramSwap.enable = true;
|
||||||
|
|
||||||
|
systemd.network = {
|
||||||
|
enable = true;
|
||||||
|
networks."10-uplink" = {
|
||||||
|
matchConfig.MACAddress = "78:55:36:07:af:49";
|
||||||
|
networkConfig.DHCP = "yes";
|
||||||
|
linkConfig.RequiredForOnline = "routable";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
networking.useDHCP = false;
|
||||||
|
|
||||||
|
boot.zfs.forceImportRoot = false;
|
||||||
|
|
||||||
|
modules.zfs = {
|
||||||
|
enable = true;
|
||||||
|
hostId = "2346edbd";
|
||||||
|
pools.pikachu = { };
|
||||||
|
};
|
||||||
|
|
||||||
|
modules.ssh.enable = true;
|
||||||
|
modules.ssh.hostKeys.sopsFile = ../../secrets/pikachu.yaml;
|
||||||
|
modules.ssh.userKey.sopsFile = ../../secrets/pikachu.yaml;
|
||||||
|
|
||||||
|
modules.git.enable = true;
|
||||||
|
modules.toolkit.enable = true;
|
||||||
|
|
||||||
|
environment.systemPackages = with pkgs; [
|
||||||
|
pciutils
|
||||||
|
smartmontools
|
||||||
|
usbutils
|
||||||
|
];
|
||||||
|
|
||||||
|
time.timeZone = "America/New_York";
|
||||||
|
i18n.defaultLocale = "en_GB.UTF-8";
|
||||||
|
}
|
||||||
32
hosts/pikachu/disk.nix
Normal file
32
hosts/pikachu/disk.nix
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{ ... }:
|
||||||
|
# pikachu's install layout for disko: one NVMe boot disk with an EFI system partition and ext4 root.
|
||||||
|
# The existing 8 TB ZFS mirror is imported by name and is never declared here.
|
||||||
|
{
|
||||||
|
disko.devices.disk.main = {
|
||||||
|
type = "disk";
|
||||||
|
device = "/dev/nvme0n1";
|
||||||
|
content = {
|
||||||
|
type = "gpt";
|
||||||
|
partitions = {
|
||||||
|
ESP = {
|
||||||
|
size = "2G";
|
||||||
|
type = "EF00";
|
||||||
|
content = {
|
||||||
|
type = "filesystem";
|
||||||
|
format = "vfat";
|
||||||
|
mountpoint = "/boot";
|
||||||
|
mountOptions = [ "umask=0077" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
root = {
|
||||||
|
size = "100%";
|
||||||
|
content = {
|
||||||
|
type = "filesystem";
|
||||||
|
format = "ext4";
|
||||||
|
mountpoint = "/";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
18
hosts/pikachu/hardware-configuration.nix
Normal file
18
hosts/pikachu/hardware-configuration.nix
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{ lib, modulesPath, ... }:
|
||||||
|
# Hardware detected from the Proxmox inventory for this machine.
|
||||||
|
# disko derives the root disk filesystems, none declared here.
|
||||||
|
{
|
||||||
|
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
|
||||||
|
|
||||||
|
boot.initrd.availableKernelModules = [
|
||||||
|
"ahci"
|
||||||
|
"nvme"
|
||||||
|
"sd_mod"
|
||||||
|
"xhci_pci"
|
||||||
|
];
|
||||||
|
boot.initrd.kernelModules = [ ];
|
||||||
|
boot.kernelModules = [ "kvm-intel" ];
|
||||||
|
boot.extraModulePackages = [ ];
|
||||||
|
|
||||||
|
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
|
||||||
|
}
|
||||||
1
hosts/pikachu/ssh_host_ed25519_key.pub
Normal file
1
hosts/pikachu/ssh_host_ed25519_key.pub
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu
|
||||||
1
hosts/pikachu/ssh_host_rsa_key.pub
Normal file
1
hosts/pikachu/ssh_host_rsa_key.pub
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu
|
||||||
202
lib.nix
202
lib.nix
@@ -96,6 +96,41 @@ let
|
|||||||
|
|
||||||
networked = cfg.vlan != null;
|
networked = cfg.vlan != null;
|
||||||
|
|
||||||
|
# Host paths the operator maps into the guest, keyed by their in-guest path.
|
||||||
|
userMounts = lib.mapAttrs (_guestPath: m: {
|
||||||
|
inherit (m) hostPath;
|
||||||
|
isReadOnly = m.readOnly;
|
||||||
|
}) cfg.mounts;
|
||||||
|
|
||||||
|
# Each named secret bind-mounted read-only at the same `/run/secrets/<name>`
|
||||||
|
# path it holds on the host.
|
||||||
|
# No ownership is set here, since the container's one-to-one identity map
|
||||||
|
# carries the host file's owner through unchanged.
|
||||||
|
secretMounts = lib.listToAttrs (
|
||||||
|
map (
|
||||||
|
name:
|
||||||
|
let
|
||||||
|
path = config.sops.secrets.${name}.path;
|
||||||
|
in
|
||||||
|
lib.nameValuePair path {
|
||||||
|
hostPath = path;
|
||||||
|
isReadOnly = true;
|
||||||
|
}
|
||||||
|
) cfg.secrets
|
||||||
|
);
|
||||||
|
|
||||||
|
# An in-guest path claimed by both a mount and a secret, which the merge
|
||||||
|
# below would otherwise resolve silently in the secret's favour.
|
||||||
|
mountCollisions = lib.attrNames (builtins.intersectAttrs userMounts secretMounts);
|
||||||
|
|
||||||
|
# The resource caps the operator places on the guest's unit, dropping any
|
||||||
|
# left unset so systemd keeps its uncapped default for those.
|
||||||
|
limitConfig = lib.filterAttrs (_: v: v != null) {
|
||||||
|
MemoryMax = cfg.limits.memory;
|
||||||
|
CPUQuota = cfg.limits.cpu;
|
||||||
|
TasksMax = cfg.limits.tasksMax;
|
||||||
|
};
|
||||||
|
|
||||||
# A networked guest owns its bridged interface through its own networkd, the only stable MAC pin for a nested container.
|
# A networked guest owns its bridged interface through its own networkd, the only stable MAC pin for a nested container.
|
||||||
# The interface is eth0, the name a nested container gives its bridged veth.
|
# The interface is eth0, the name a nested container gives its bridged veth.
|
||||||
# It takes the placement MAC, and the static address or DHCP when that is unset.
|
# It takes the placement MAC, and the static address or DHCP when that is unset.
|
||||||
@@ -104,6 +139,11 @@ let
|
|||||||
{
|
{
|
||||||
config = lib.mkIf networked {
|
config = lib.mkIf networked {
|
||||||
networking.useNetworkd = true;
|
networking.useNetworkd = true;
|
||||||
|
|
||||||
|
# networkd default-enables resolved, which owns the guest's resolv.conf.
|
||||||
|
# The nested-container default of inheriting the host's file conflicts with that, so the guest keeps its own.
|
||||||
|
networking.useHostResolvConf = false;
|
||||||
|
|
||||||
systemd.network.networks."20-eth0" = {
|
systemd.network.networks."20-eth0" = {
|
||||||
matchConfig.Name = "eth0";
|
matchConfig.Name = "eth0";
|
||||||
linkConfig.MACAddress = cfg.mac;
|
linkConfig.MACAddress = cfg.mac;
|
||||||
@@ -160,10 +200,125 @@ let
|
|||||||
guest takes its address by DHCP, keeping IP management at the router.
|
guest takes its address by DHCP, keeping IP management at the router.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
mounts = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf (
|
||||||
|
lib.types.submodule {
|
||||||
|
options = {
|
||||||
|
hostPath = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
example = "/srv/media";
|
||||||
|
description = "The path on the host bind-mounted into the guest.";
|
||||||
|
};
|
||||||
|
readOnly = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Mount the path read-only. Read-write by default, since a
|
||||||
|
service must write to the pool data it owns.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
default = { };
|
||||||
|
example = lib.literalExpression ''
|
||||||
|
{
|
||||||
|
"/data/media" = { hostPath = "/srv/media"; };
|
||||||
|
"/data/config" = {
|
||||||
|
hostPath = "/srv/config/jellyfin";
|
||||||
|
readOnly = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
description = ''
|
||||||
|
Host paths bind-mounted into the guest, keyed by the path they appear
|
||||||
|
at inside the guest, so a guest sees exactly the data it should at any
|
||||||
|
granularity — a single folder or a whole pool. Each mount is
|
||||||
|
read-write unless `readOnly` is set.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
secrets = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [ "jellyfin-api-key" ];
|
||||||
|
description = ''
|
||||||
|
Names of the secrets this guest needs. The host is the sole
|
||||||
|
decryptor: it decrypts each named secret from its own sops files and
|
||||||
|
bind-mounts the plaintext file into the guest read-only at
|
||||||
|
`/run/secrets/<name>`, the same path it would occupy on a host, so a
|
||||||
|
service reads its credentials at a predictable location. The guest
|
||||||
|
names the files it wants and receives exactly those. It holds no age
|
||||||
|
key and decrypts nothing itself. Ownership carries across unchanged,
|
||||||
|
since the container maps ids one to one, so a secret owned by a uid on
|
||||||
|
the host is owned by that same uid inside the guest.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
limits = {
|
||||||
|
memory = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "2G";
|
||||||
|
description = ''
|
||||||
|
Cap on the guest's memory, applied to its unit as `MemoryMax`.
|
||||||
|
Accepts systemd size suffixes such as `512M` or `2G`. Left null,
|
||||||
|
the guest's memory is uncapped.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
cpu = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "150%";
|
||||||
|
description = ''
|
||||||
|
Cap on the guest's CPU, applied to its unit as `CPUQuota`, where
|
||||||
|
`100%` is one full core. Left null, the guest's CPU is uncapped.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
tasksMax = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.ints.positive;
|
||||||
|
default = null;
|
||||||
|
example = 512;
|
||||||
|
description = ''
|
||||||
|
Cap on the number of processes and threads the guest may spawn,
|
||||||
|
applied to its unit as `TasksMax`. Left null, the task count is
|
||||||
|
uncapped.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
nesting = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = false;
|
||||||
|
description = ''
|
||||||
|
Grant the guest's interior the prerequisites to run Podman or other
|
||||||
|
OCI containers of its own. Off by default, so a guest cannot nest
|
||||||
|
containers. On, the guest's container gains the network-administration
|
||||||
|
capability its container runtime uses to build bridges and firewall
|
||||||
|
rules, along with the tun and fuse device nodes such a runtime reaches
|
||||||
|
for, so the interior's `virtualisation.oci-containers` works with
|
||||||
|
Podman as its default runtime.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
autoStart = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = ''
|
||||||
|
Start the guest at boot. On by default. Disabled, the guest stays
|
||||||
|
defined and can be started on demand, but does not come up at boot.
|
||||||
|
'';
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
|
# Declared here so the host is the one that decrypts each named secret.
|
||||||
|
# The guest carries no age key and decrypts nothing of its own.
|
||||||
|
sops.secrets = lib.genAttrs cfg.secrets (_: { });
|
||||||
|
|
||||||
assertions = [
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = mountCollisions == [ ];
|
||||||
|
message = ''
|
||||||
|
guests.${name} maps a mount at ${lib.concatStringsSep ", " mountCollisions}, colliding with a secret bind-mounted at the same path. Rename the mount or the secret so each in-guest path is used once.
|
||||||
|
'';
|
||||||
|
}
|
||||||
{
|
{
|
||||||
assertion = cfg.backend == "container";
|
assertion = cfg.backend == "container";
|
||||||
message = ''
|
message = ''
|
||||||
@@ -178,8 +333,27 @@ let
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# The operator's resource caps land on the guest's own unit, which a
|
||||||
|
# networked guest also orders after the bridge its veth enslaves to at
|
||||||
|
# start, since the container backend orders the unit after the network
|
||||||
|
# is up but not after that specific bridge existing.
|
||||||
|
systemd.services."container@${machineName}" = lib.mkIf (cfg.backend == "container") (
|
||||||
|
lib.mkMerge [
|
||||||
|
{ serviceConfig = limitConfig; }
|
||||||
|
(lib.mkIf networked (
|
||||||
|
let
|
||||||
|
bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (bridgeName cfg.vlan)}.device";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
after = [ bridgeDevice ];
|
||||||
|
wants = [ bridgeDevice ];
|
||||||
|
}
|
||||||
|
))
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
containers.${machineName} = lib.mkIf (cfg.backend == "container") {
|
containers.${machineName} = lib.mkIf (cfg.backend == "container") {
|
||||||
autoStart = lib.mkDefault true;
|
autoStart = cfg.autoStart;
|
||||||
|
|
||||||
# The guest gets its own network namespace, so its services — its own
|
# The guest gets its own network namespace, so its services — its own
|
||||||
# sshd included — never contend with the host's.
|
# sshd included — never contend with the host's.
|
||||||
@@ -189,6 +363,32 @@ let
|
|||||||
# a first-class L2 citizen on that segment.
|
# a first-class L2 citizen on that segment.
|
||||||
hostBridge = lib.mkIf networked (bridgeName cfg.vlan);
|
hostBridge = lib.mkIf networked (bridgeName cfg.vlan);
|
||||||
|
|
||||||
|
# The container shares the host's uid and gid space one to one.
|
||||||
|
# A guest process writing as the shared storage group then lands on a bind-mounted pool as that same group, with no permission juggling.
|
||||||
|
# A private-user mapping would shift the ids and reintroduce those errors, so it stays off.
|
||||||
|
privateUsers = lib.mkDefault "no";
|
||||||
|
|
||||||
|
# A nesting guest runs Podman or other OCI containers in its interior.
|
||||||
|
# The network-administration capability lets that runtime build its
|
||||||
|
# bridges and firewall rules.
|
||||||
|
# The tun and fuse device nodes are what it reaches for to network
|
||||||
|
# those containers and back their overlay storage.
|
||||||
|
# The remaining prerequisite, a delegated cgroup subtree for the
|
||||||
|
# runtime to manage, the container backend already grants every guest.
|
||||||
|
additionalCapabilities = lib.optionals cfg.nesting [ "CAP_NET_ADMIN" ];
|
||||||
|
allowedDevices = lib.optionals cfg.nesting [
|
||||||
|
{
|
||||||
|
node = "/dev/net/tun";
|
||||||
|
modifier = "rwm";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
node = "/dev/fuse";
|
||||||
|
modifier = "rwm";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
bindMounts = userMounts // secretMounts;
|
||||||
|
|
||||||
inherit specialArgs;
|
inherit specialArgs;
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ in
|
|||||||
programs.claude-code = {
|
programs.claude-code = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
|
||||||
# The global agent-instructions file.
|
|
||||||
context = ./CLAUDE.md;
|
|
||||||
|
|
||||||
# One directory per skill, symlinked under ~/.claude/skills.
|
# One directory per skill, symlinked under ~/.claude/skills.
|
||||||
skills = ./skills;
|
skills = ./skills;
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,14 @@ These are common instructions for Alexion's agents across all scenarios.
|
|||||||
It means: don't discount a more robust or maintainable approach just because it would take a human a long time to build.
|
It means: don't discount a more robust or maintainable approach just because it would take a human a long time to build.
|
||||||
- File names should always be lower case, unless there's a valid reason.
|
- File names should always be lower case, unless there's a valid reason.
|
||||||
Established ecosystem or tool conventions count as a valid reason automatically (e.g. `README.md`, `LICENSE`, `CHANGELOG.md`, `Makefile`, `Dockerfile`, `.github/` files), without needing to ask each time.
|
Established ecosystem or tool conventions count as a valid reason automatically (e.g. `README.md`, `LICENSE`, `CHANGELOG.md`, `Makefile`, `Dockerfile`, `.github/` files), without needing to ask each time.
|
||||||
|
- Do not end a response by promising or implying continuation unless the continuation is present in that same response.
|
||||||
|
If a workflow should continue, perform the next step before ending the turn.
|
||||||
|
If the workflow is paused, say that plainly instead of using a dangling transition like "continuing" or "next".
|
||||||
- When you discover that a belief you held about an objective fact or convention of the current project was wrong, write it down so it isn't relearned next time.
|
- When you discover that a belief you held about an objective fact or convention of the current project was wrong, write it down so it isn't relearned next time.
|
||||||
This applies whether the user corrected you or you caught the mistake yourself, and only to things that are true regardless of who is operating the project (a wrong build command, a wrong file path, a convention you guessed at instead of checking) — not personal working-style preferences or one-off task details.
|
This applies whether the user corrected you or you caught the mistake yourself, and only to things that are true regardless of who is operating the project (a wrong build command, a wrong file path, a convention you guessed at instead of checking) — not personal working-style preferences or one-off task details.
|
||||||
Record it in that project's own CLAUDE.md, not this global file, under a dedicated `## Gotchas` section (create the section if the file doesn't have one yet).
|
Record it in that project's own AGENTS.md, not this global file, under a dedicated `## Gotchas` section (create the section if the file doesn't have one yet).
|
||||||
If the project has nested CLAUDE.md files, use the one nearest to where the mistake occurred, falling back to the project's top-level CLAUDE.md.
|
If the project has nested AGENTS.md files, use the one nearest to where the mistake occurred, falling back to the project's top-level AGENTS.md.
|
||||||
Append to an existing CLAUDE.md immediately, without asking; if no CLAUDE.md exists yet for the project, ask before creating one.
|
Append to an existing AGENTS.md immediately, without asking; if no AGENTS.md exists yet for the project, ask before creating one.
|
||||||
Briefly mention the edit in your response rather than making it silently.
|
Briefly mention the edit in your response rather than making it silently.
|
||||||
If an existing entry is later found to be wrong or stale, correct or remove it the same way.
|
If an existing entry is later found to be wrong or stale, correct or remove it the same way.
|
||||||
|
|
||||||
@@ -46,7 +49,7 @@ These are common instructions for Alexion's agents across all scenarios.
|
|||||||
Do not write about history ("used to be X", "now moved here") or future state, about how a value is consumed elsewhere, or to justify the choice against alternatives; state the positive reason a thing exists, keeping any real stakes as a present-tense consequence.
|
Do not write about history ("used to be X", "now moved here") or future state, about how a value is consumed elsewhere, or to justify the choice against alternatives; state the positive reason a thing exists, keeping any real stakes as a present-tense consequence.
|
||||||
The only permitted cross-file mention is a bare pointer explaining why something is *absent* here (e.g. a value another tool derives, which this file therefore does not declare), never narrating what the other file or tool does.
|
The only permitted cross-file mention is a bare pointer explaining why something is *absent* here (e.g. a value another tool derives, which this file therefore does not declare), never narrating what the other file or tool does.
|
||||||
Do not use a project's domain-model or ubiquitous-language capitalized terms as glossary references; describe things in plain language, using ordinary lowercase nouns.
|
Do not use a project's domain-model or ubiquitous-language capitalized terms as glossary references; describe things in plain language, using ordinary lowercase nouns.
|
||||||
Never reference agent-facing state (anything under `.claude/` or `CLAUDE.md`).
|
Never reference agent-facing state (anything under `.agents/`, `.claude/`, `AGENTS.md`, or `CLAUDE.md`).
|
||||||
A file-top header is one concise purpose line, added only where the filename or path does not already say it — never a feature inventory of the code below.
|
A file-top header is one concise purpose line, added only where the filename or path does not already say it — never a feature inventory of the code below.
|
||||||
For a placeholder, say so plainly plus any actionable present-tense directive ("Placeholder: regenerate with <tool> on the target machine"), never "placeholder for <missing feature>".
|
For a placeholder, say so plainly plus any actionable present-tense directive ("Placeholder: regenerate with <tool> on the target machine"), never "placeholder for <missing feature>".
|
||||||
User-facing documentation strings (an option's `description`, a generated help string) are documentation rather than comments, so they may describe behaviour more fully — but the self-contained rule and the bans on glossary terms and agent-state references still apply.
|
User-facing documentation strings (an option's `description`, a generated help string) are documentation rather than comments, so they may describe behaviour more fully — but the self-contained rule and the bans on glossary terms and agent-state references still apply.
|
||||||
21
modules/agents/context/context.nix
Normal file
21
modules/agents/context/context.nix
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# Shared global instructions for agent harnesses.
|
||||||
|
let
|
||||||
|
user = config.user.name;
|
||||||
|
context = builtins.readFile ./AGENTS.md;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
config = lib.mkMerge [
|
||||||
|
(lib.mkIf config.modules.agents.claude-code.enable {
|
||||||
|
home-manager.users.${user}.programs.claude-code.context = context;
|
||||||
|
})
|
||||||
|
|
||||||
|
(lib.mkIf config.modules.agents.pi.enable {
|
||||||
|
home-manager.users.${user}.programs.pi-coding-agent.context = context;
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
42
modules/agents/herdr.nix
Normal file
42
modules/agents/herdr.nix
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# Herdr, a terminal multiplexer for coding agents.
|
||||||
|
let
|
||||||
|
cfg = config.modules.agents.herdr;
|
||||||
|
user = config.user.name;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.modules.agents.herdr.enable = lib.mkEnableOption "Herdr, a terminal multiplexer for coding agents";
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
home-manager.users.${user} = {
|
||||||
|
home.packages = [ pkgs.herdr ];
|
||||||
|
|
||||||
|
xdg.configFile."herdr/config.toml".text = ''
|
||||||
|
[keys]
|
||||||
|
prefix = "ctrl+space"
|
||||||
|
detach = "prefix+d"
|
||||||
|
reload_config = "prefix+r"
|
||||||
|
new_workspace = "prefix+c"
|
||||||
|
new_tab = "prefix+shift+c"
|
||||||
|
rename_workspace = "prefix+comma"
|
||||||
|
rename_tab = "prefix+<"
|
||||||
|
split_vertical = "prefix+backslash"
|
||||||
|
split_horizontal = "prefix+minus"
|
||||||
|
switch_workspace = "prefix+1..9"
|
||||||
|
switch_tab = "prefix+shift+1..9"
|
||||||
|
focus_pane_left = "prefix+h"
|
||||||
|
focus_pane_down = "prefix+j"
|
||||||
|
focus_pane_up = "prefix+k"
|
||||||
|
focus_pane_right = "prefix+l"
|
||||||
|
|
||||||
|
[ui]
|
||||||
|
prompt_new_tab_name = false
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
config,
|
|
||||||
lib,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
# Pi, a terminal coding agent, for the primary user, configured through
|
|
||||||
# home-manager, which ships the package and manages ~/.pi/agent.
|
|
||||||
# The login credential is left unmanaged, so it survives rebuilds.
|
|
||||||
let
|
|
||||||
cfg = config.modules.agents.pi;
|
|
||||||
user = config.user.name;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
options.modules.agents.pi.enable = lib.mkEnableOption ''
|
|
||||||
Pi, a terminal coding agent, configured via home-manager'';
|
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
|
||||||
home-manager.users.${user}.programs.pi-coding-agent = {
|
|
||||||
enable = true;
|
|
||||||
|
|
||||||
settings = {
|
|
||||||
defaultProvider = "anthropic";
|
|
||||||
# Pi's catalogue id for Opus.
|
|
||||||
defaultModel = "claude-opus-4-8";
|
|
||||||
enableAnalytics = false;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
0
modules/agents/pi/extensions/.gitkeep
Normal file
0
modules/agents/pi/extensions/.gitkeep
Normal file
254
modules/agents/pi/extensions/compact-status.ts
Normal file
254
modules/agents/pi/extensions/compact-status.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||||
|
|
||||||
|
type QuotaState =
|
||||||
|
| { status: "idle" | "loading" }
|
||||||
|
| { status: "ok"; detail: string; refreshedAt: number; weeklyRemaining?: number; shortRemaining?: number }
|
||||||
|
| { status: "missing" | "error"; detail: string; refreshedAt?: number };
|
||||||
|
|
||||||
|
const CODEX_USAGE_ENDPOINTS = [
|
||||||
|
"https://chatgpt.com/backend-api/wham/usage",
|
||||||
|
"https://chatgpt.com/backend-api/codex/usage",
|
||||||
|
];
|
||||||
|
const QUOTA_REFRESH_MS = 5 * 60 * 1000;
|
||||||
|
const REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
let quotaState: QuotaState = { status: "idle" };
|
||||||
|
let quotaRefreshPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function shortCwd(cwd: string): string {
|
||||||
|
const home = process.env.HOME;
|
||||||
|
if (home && cwd.startsWith(`${home}/`)) return `~/${basename(cwd)}`;
|
||||||
|
return basename(cwd) || cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitBranch(cwd: string): string | null {
|
||||||
|
try {
|
||||||
|
const out = execFileSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
||||||
|
cwd,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
}).trim();
|
||||||
|
return out || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authPath(): string {
|
||||||
|
return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "auth.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCodexCredentials(): { access: string; accountId?: string } | null {
|
||||||
|
const file = authPath();
|
||||||
|
if (!existsSync(file)) return null;
|
||||||
|
try {
|
||||||
|
const auth = JSON.parse(readFileSync(file, "utf8"));
|
||||||
|
const credential = auth?.["openai-codex"];
|
||||||
|
if (credential?.type !== "oauth" || typeof credential.access !== "string") return null;
|
||||||
|
if (typeof credential.expires === "number" && credential.expires <= Date.now() + 30_000) return null;
|
||||||
|
return {
|
||||||
|
access: credential.access,
|
||||||
|
accountId: typeof credential.accountId === "string" ? credential.accountId : undefined,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown): number | undefined {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string" && value.trim() !== "") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowSeconds(raw: Record<string, unknown>): number | undefined {
|
||||||
|
const seconds = numberValue(raw.limit_window_seconds ?? raw.windowSeconds);
|
||||||
|
if (seconds !== undefined) return seconds;
|
||||||
|
const mins = numberValue(raw.windowDurationMins ?? raw.window_duration_mins);
|
||||||
|
return mins === undefined ? undefined : mins * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usedPercent(raw: Record<string, unknown>): number | undefined {
|
||||||
|
const value = numberValue(raw.used_percent ?? raw.usedPercent);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
return Math.max(0, Math.min(100, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectWindows(raw: unknown, out: Array<{ seconds?: number; used: number; key: string }> = [], key = "root") {
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
raw.forEach((item, index) => collectWindows(item, out, `${key}.${index}`));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const obj = objectValue(raw);
|
||||||
|
if (!obj) return out;
|
||||||
|
const used = usedPercent(obj);
|
||||||
|
if (used !== undefined) out.push({ seconds: windowSeconds(obj), used, key });
|
||||||
|
for (const [childKey, value] of Object.entries(obj)) {
|
||||||
|
if (value && typeof value === "object") collectWindows(value, out, `${key}.${childKey}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickQuotaWindows(raw: unknown): { weeklyRemaining?: number; shortRemaining?: number } | null {
|
||||||
|
const windows = collectWindows(raw);
|
||||||
|
if (windows.length === 0) return null;
|
||||||
|
const weekly = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 604_800) <= 60 * 60)
|
||||||
|
?? windows.find((window) => /week|weekly|secondary/i.test(window.key));
|
||||||
|
const short = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 18_000) <= 60 * 30)
|
||||||
|
?? windows.find((window) => /five|session|primary|short/i.test(window.key));
|
||||||
|
return {
|
||||||
|
weeklyRemaining: weekly ? Math.max(0, Math.min(100, 100 - weekly.used)) : undefined,
|
||||||
|
shortRemaining: short ? Math.max(0, Math.min(100, 100 - short.used)) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFg(theme: any, color: string, text: string): string {
|
||||||
|
try {
|
||||||
|
return theme.fg(color, text);
|
||||||
|
} catch {
|
||||||
|
return theme.fg("accent", text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextColor(percent: number): string {
|
||||||
|
if (percent >= 90) return "error";
|
||||||
|
if (percent >= 70) return "warning";
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotaColor(percent: number): string {
|
||||||
|
if (percent >= 80) return "error";
|
||||||
|
if (percent >= 50) return "warning";
|
||||||
|
return "border";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bar(theme: any, width: number, percent: number | null, glyph: string, colorForPercent: (percent: number) => string): string {
|
||||||
|
const barWidth = Math.max(12, width);
|
||||||
|
if (percent === null) return theme.fg("muted", glyph.repeat(barWidth));
|
||||||
|
const clamped = Math.max(0, Math.min(100, percent));
|
||||||
|
const filled = Math.max(0, Math.min(barWidth, Math.round((clamped / 100) * barWidth)));
|
||||||
|
const empty = Math.max(0, barWidth - filled);
|
||||||
|
return safeFg(theme, colorForPercent(clamped), glyph.repeat(filled)) + theme.fg("dim", glyph.repeat(empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCodexQuota(force = false): Promise<void> {
|
||||||
|
const fresh = quotaState.status === "ok" && Date.now() - quotaState.refreshedAt < QUOTA_REFRESH_MS;
|
||||||
|
if (!force && fresh) return;
|
||||||
|
if (quotaRefreshPromise) return quotaRefreshPromise;
|
||||||
|
|
||||||
|
quotaState = { status: "loading" };
|
||||||
|
quotaRefreshPromise = (async () => {
|
||||||
|
const credentials = readCodexCredentials();
|
||||||
|
if (!credentials) {
|
||||||
|
quotaState = { status: "missing", detail: "OpenAI Codex OAuth credentials were not found or are expired" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError = "quota unavailable";
|
||||||
|
for (const endpoint of CODEX_USAGE_ENDPOINTS) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { Authorization: `Bearer ${credentials.access}` };
|
||||||
|
if (credentials.accountId) headers["ChatGPT-Account-Id"] = credentials.accountId;
|
||||||
|
const response = await fetch(endpoint, { headers, signal: controller.signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
lastError = `${response.status} ${response.statusText}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const windows = pickQuotaWindows(await response.json());
|
||||||
|
if (!windows || (windows.weeklyRemaining === undefined && windows.shortRemaining === undefined)) {
|
||||||
|
lastError = "response had no recognized quota windows";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const details = [];
|
||||||
|
if (windows.weeklyRemaining !== undefined) details.push(`weekly ${Math.round(windows.weeklyRemaining)}%`);
|
||||||
|
if (windows.shortRemaining !== undefined) details.push(`short ${Math.round(windows.shortRemaining)}%`);
|
||||||
|
quotaState = {
|
||||||
|
status: "ok",
|
||||||
|
detail: `Codex quota remaining: ${details.join(", ")}`,
|
||||||
|
weeklyRemaining: windows.weeklyRemaining,
|
||||||
|
shortRemaining: windows.shortRemaining,
|
||||||
|
refreshedAt: Date.now(),
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quotaState = { status: "error", detail: `Codex quota failed: ${lastError}`, refreshedAt: Date.now() };
|
||||||
|
})().finally(() => {
|
||||||
|
quotaRefreshPromise = null;
|
||||||
|
});
|
||||||
|
return quotaRefreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLines(ctx: any, theme: any, width: number): string[] {
|
||||||
|
const cwd = ctx.sessionManager?.getCwd?.() ?? ctx.cwd ?? process.cwd();
|
||||||
|
const branch = gitBranch(cwd);
|
||||||
|
const where = branch ? ` ${shortCwd(cwd)} ${branch}` : ` ${shortCwd(cwd)}`;
|
||||||
|
const model = ctx.model?.id ?? process.env.PI_MODEL ?? "no-model";
|
||||||
|
const thinking = ctx.thinkingLevel ?? process.env.PI_REASONING_LEVEL ?? "off";
|
||||||
|
const left = theme.fg("accent", where);
|
||||||
|
const right = theme.fg("dim", `${model} • ${thinking}`);
|
||||||
|
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
|
||||||
|
const contextPercentRaw = ctx.getContextUsage?.()?.percent;
|
||||||
|
const contextPercent = typeof contextPercentRaw === "number" && Number.isFinite(contextPercentRaw) ? contextPercentRaw : null;
|
||||||
|
const quotaConsumed = quotaState.status === "ok" && quotaState.weeklyRemaining !== undefined
|
||||||
|
? 100 - quotaState.weeklyRemaining
|
||||||
|
: null;
|
||||||
|
return [
|
||||||
|
truncateToWidth(left + pad + right, width),
|
||||||
|
bar(theme, width, contextPercent, "▃", contextColor),
|
||||||
|
bar(theme, width, quotaConsumed, "▔", quotaColor),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCompactStatusUi(ctx: any) {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
ctx.ui.setWidget("compact-status", (_tui: any, theme: any) => ({
|
||||||
|
invalidate() {},
|
||||||
|
render(width: number) {
|
||||||
|
return statusLines(ctx, theme, width);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
ctx.ui.setFooter(() => ({ invalidate() {}, render: () => [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function compactStatus(pi: ExtensionAPI) {
|
||||||
|
function refreshUi(ctx: any) {
|
||||||
|
setCompactStatusUi(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.on("session_start", (_event, ctx) => {
|
||||||
|
refreshUi(ctx);
|
||||||
|
void fetchCodexQuota(false).then(() => refreshUi(ctx));
|
||||||
|
});
|
||||||
|
pi.on("model_select", (_event, ctx) => refreshUi(ctx));
|
||||||
|
pi.on("agent_settled", (_event, ctx) => refreshUi(ctx));
|
||||||
|
|
||||||
|
pi.registerCommand("codex-quota", {
|
||||||
|
description: "Refresh and show ChatGPT Codex quota",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
refreshUi(ctx);
|
||||||
|
await fetchCodexQuota(true);
|
||||||
|
refreshUi(ctx);
|
||||||
|
const level = quotaState.status === "ok" ? "info" : quotaState.status === "missing" ? "warning" : "error";
|
||||||
|
ctx.ui.notify(quotaState.status === "idle" || quotaState.status === "loading" ? "Codex quota refresh in progress" : quotaState.detail, level);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
141
modules/agents/pi/extensions/subagents/agents.ts
Normal file
141
modules/agents/pi/extensions/subagents/agents.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import type { ContextMode } from "./types.ts";
|
||||||
|
import type { Diagnostics } from "./config.ts";
|
||||||
|
|
||||||
|
export interface AgentDefinition {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
body: string;
|
||||||
|
context?: ContextMode;
|
||||||
|
model?: string;
|
||||||
|
thinking?: string;
|
||||||
|
tools?: string;
|
||||||
|
allowedContexts?: ContextMode[];
|
||||||
|
hidden?: boolean;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAgents(cwd: string, projectTrusted: boolean, diagnostics: Diagnostics, agentDir = defaultAgentDir()): Map<string, AgentDefinition> {
|
||||||
|
const user = loadTier(join(agentDir, "agents"), "user", diagnostics);
|
||||||
|
const project = projectTrusted ? loadTier(join(cwd, ".pi", "agents"), "project", diagnostics) : new Map<string, AgentDefinition>();
|
||||||
|
return new Map([...user, ...project]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTier(dir: string, tier: string, diagnostics: Diagnostics): Map<string, AgentDefinition> {
|
||||||
|
const agents = new Map<string, AgentDefinition>();
|
||||||
|
if (!existsSync(dir)) return agents;
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
||||||
|
const path = join(dir, entry.name);
|
||||||
|
const parsed = parseAgent(path, diagnostics);
|
||||||
|
if (!parsed) continue;
|
||||||
|
if (agents.has(parsed.name)) {
|
||||||
|
diagnostics.warnings.push(`Duplicate ${tier} agent '${parsed.name}' ignored at ${path}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const stem = basename(entry.name, ".md");
|
||||||
|
if (stem !== parsed.name) diagnostics.warnings.push(`${tier} agent file '${entry.name}' name '${parsed.name}' does not match filename`);
|
||||||
|
agents.set(parsed.name, parsed);
|
||||||
|
}
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAgent(path: string, diagnostics: Diagnostics): AgentDefinition | undefined {
|
||||||
|
try {
|
||||||
|
const text = readFileSync(path, "utf8");
|
||||||
|
const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/u.exec(text);
|
||||||
|
if (!match) {
|
||||||
|
diagnostics.warnings.push(`Agent ${path} missing YAML frontmatter`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const frontmatter = parseFrontmatter(match[1]);
|
||||||
|
const name = stringField(frontmatter, "name");
|
||||||
|
const description = stringField(frontmatter, "description");
|
||||||
|
if (!name || !/^[a-z0-9-]+$/.test(name)) {
|
||||||
|
diagnostics.warnings.push(`Agent ${path} has invalid name`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!description) {
|
||||||
|
diagnostics.warnings.push(`Agent ${path} has invalid description`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const context = contextField(frontmatter.context);
|
||||||
|
const allowedContexts = contextsField(frontmatter.allowedContexts);
|
||||||
|
if (frontmatter.context !== undefined && !context) diagnostics.warnings.push(`Agent ${path} has invalid context`);
|
||||||
|
if (frontmatter.allowedContexts !== undefined && !allowedContexts) diagnostics.warnings.push(`Agent ${path} has invalid allowedContexts`);
|
||||||
|
if (context && allowedContexts && !allowedContexts.includes(context)) diagnostics.warnings.push(`Agent ${path} context is outside allowedContexts`);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
body: match[2].trim(),
|
||||||
|
context,
|
||||||
|
model: stringField(frontmatter, "model"),
|
||||||
|
thinking: stringField(frontmatter, "thinking"),
|
||||||
|
tools: stringField(frontmatter, "tools"),
|
||||||
|
allowedContexts,
|
||||||
|
hidden: booleanField(frontmatter, "hidden"),
|
||||||
|
source: path,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
diagnostics.warnings.push(`Failed to load agent ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(text: string): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
const lines = text.split(/\r?\n/u);
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
||||||
|
const scalar = /^(\w+):\s*(.*?)\s*$/u.exec(line);
|
||||||
|
if (!scalar) continue;
|
||||||
|
const [, key, raw] = scalar;
|
||||||
|
if (raw !== "") {
|
||||||
|
result[key] = parseScalar(raw);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const values: string[] = [];
|
||||||
|
while (i + 1 < lines.length) {
|
||||||
|
const item = /^\s+-\s*(.*?)\s*$/u.exec(lines[i + 1]);
|
||||||
|
if (!item) break;
|
||||||
|
values.push(String(parseScalar(item[1])));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
result[key] = values;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScalar(raw: string): string | boolean {
|
||||||
|
const unquoted = raw.replace(/^['"]|['"]$/gu, "");
|
||||||
|
if (unquoted === "true") return true;
|
||||||
|
if (unquoted === "false") return false;
|
||||||
|
return unquoted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(record: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = record[key];
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanField(record: Record<string, unknown>, key: string): boolean | undefined {
|
||||||
|
const value = record[key];
|
||||||
|
return typeof value === "boolean" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextField(value: unknown): ContextMode | undefined {
|
||||||
|
return value === "independent" || value === "fork" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextsField(value: unknown): ContextMode[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const contexts = value.map(contextField);
|
||||||
|
return contexts.every(Boolean) ? (contexts as ContextMode[]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultAgentDir(): string {
|
||||||
|
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
||||||
|
}
|
||||||
139
modules/agents/pi/extensions/subagents/config.test.ts
Normal file
139
modules/agents/pi/extensions/subagents/config.test.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { loadAgents } from "./agents.ts";
|
||||||
|
import { BUILT_IN_TOOL_PROFILES, loadConfig, resolveSpawn, type Diagnostics } from "./config.ts";
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "subagents-config-"));
|
||||||
|
const agentDir = join(root, "agent");
|
||||||
|
const cwd = join(root, "project");
|
||||||
|
mkdirSync(agentDir, { recursive: true });
|
||||||
|
mkdirSync(cwd, { recursive: true });
|
||||||
|
return { root, agentDir, cwd };
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostics(): Diagnostics {
|
||||||
|
return { warnings: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("missing config files and agent directories are normal", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
const diag = diagnostics();
|
||||||
|
|
||||||
|
const config = loadConfig(cwd, true, diag, agentDir);
|
||||||
|
const agents = loadAgents(cwd, true, diag, agentDir);
|
||||||
|
|
||||||
|
assert.equal(config.defaultContext, "independent");
|
||||||
|
assert.equal(config.defaultTools, "read-only");
|
||||||
|
assert.equal(config.recentTerminalTtlMs, 300000);
|
||||||
|
assert.equal(agents.size, 0);
|
||||||
|
assert.deepEqual(diag.warnings, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("global and trusted project config merge in order", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ defaultTools: "global-profile", recentTerminalTtlMs: 1000, toolProfiles: { "global-profile": { activeTools: ["read"] } } }));
|
||||||
|
writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ defaultTools: "project-profile", recentTerminalTtlMs: 2000, toolProfiles: { "project-profile": { activeTools: ["ls"] } } }));
|
||||||
|
|
||||||
|
const config = loadConfig(cwd, true, diagnostics(), agentDir);
|
||||||
|
|
||||||
|
assert.equal(config.defaultTools, "project-profile");
|
||||||
|
assert.equal(config.recentTerminalTtlMs, 2000);
|
||||||
|
assert.deepEqual(config.toolProfiles["global-profile"].activeTools, ["read"]);
|
||||||
|
assert.deepEqual(config.toolProfiles["project-profile"].activeTools, ["ls"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recent terminal ttl preserves zero and rejects invalid values", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ recentTerminalTtlMs: 0 }));
|
||||||
|
const zeroDiag = diagnostics();
|
||||||
|
|
||||||
|
const zeroConfig = loadConfig(cwd, true, zeroDiag, agentDir);
|
||||||
|
|
||||||
|
assert.equal(zeroConfig.recentTerminalTtlMs, 0);
|
||||||
|
assert.deepEqual(zeroDiag.warnings, []);
|
||||||
|
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ recentTerminalTtlMs: -1 }));
|
||||||
|
const invalidDiag = diagnostics();
|
||||||
|
|
||||||
|
const invalidConfig = loadConfig(cwd, true, invalidDiag, agentDir);
|
||||||
|
|
||||||
|
assert.equal(invalidConfig.recentTerminalTtlMs, 300000);
|
||||||
|
assert.ok(invalidDiag.warnings.some((warning) => warning.includes("Invalid global recentTerminalTtlMs ignored")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("project config is ignored when project is untrusted", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
||||||
|
writeFileSync(join(cwd, ".pi", "subagents.json"), JSON.stringify({ defaultTools: "project-profile", toolProfiles: { "project-profile": { activeTools: ["ls"] } } }));
|
||||||
|
|
||||||
|
const config = loadConfig(cwd, false, diagnostics(), agentDir);
|
||||||
|
|
||||||
|
assert.equal(config.defaultTools, "read-only");
|
||||||
|
assert.equal(config.toolProfiles["project-profile"], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agents load with project precedence over user", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
mkdirSync(join(agentDir, "agents"), { recursive: true });
|
||||||
|
mkdirSync(join(cwd, ".pi", "agents"), { recursive: true });
|
||||||
|
writeFileSync(join(agentDir, "agents", "review.md"), "---\nname: review\ndescription: User review\ntools: read-only\n---\nuser body\n");
|
||||||
|
writeFileSync(join(cwd, ".pi", "agents", "review.md"), "---\nname: review\ndescription: Project review\ntools: full-tools\n---\nproject body\n");
|
||||||
|
|
||||||
|
const agents = loadAgents(cwd, true, diagnostics(), agentDir);
|
||||||
|
|
||||||
|
assert.equal(agents.get("review")?.description, "Project review");
|
||||||
|
assert.equal(agents.get("review")?.body, "project body");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("duplicate same-tier definitions and invalid frontmatter produce diagnostics", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
const dir = join(agentDir, "agents");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, "one.md"), "---\nname: same\ndescription: One\n---\none\n");
|
||||||
|
writeFileSync(join(dir, "two.md"), "---\nname: same\ndescription: Two\n---\ntwo\n");
|
||||||
|
writeFileSync(join(dir, "bad.md"), "---\nname: Bad Name\n---\nbad\n");
|
||||||
|
const diag = diagnostics();
|
||||||
|
|
||||||
|
const agents = loadAgents(cwd, true, diag, agentDir);
|
||||||
|
|
||||||
|
assert.equal(agents.size, 1);
|
||||||
|
assert.ok(diag.warnings.some((warning) => warning.includes("Duplicate user agent 'same'")));
|
||||||
|
assert.ok(diag.warnings.some((warning) => warning.includes("invalid name")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("named spawn resolves overrides, frontmatter, config, and defaults", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
mkdirSync(join(agentDir, "agents"), { recursive: true });
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ defaultTools: "local-review", toolProfiles: { "local-review": { activeTools: ["read"] } } }));
|
||||||
|
writeFileSync(join(agentDir, "agents", "review.md"), "---\nname: review\ndescription: Review\ncontext: independent\nmodel: inherit\nthinking: high\ntools: local-review\n---\nagent body\n");
|
||||||
|
const diag = diagnostics();
|
||||||
|
const config = loadConfig(cwd, true, diag, agentDir);
|
||||||
|
const agents = loadAgents(cwd, true, diag, agentDir);
|
||||||
|
|
||||||
|
const resolved = resolveSpawn({ agent: "review", prompt: "check this", label: "Review migration", thinking: "low" }, config, agents);
|
||||||
|
|
||||||
|
assert.equal(resolved.prompt, "check this");
|
||||||
|
assert.equal(resolved.label, "Review migration");
|
||||||
|
assert.equal(resolved.context, "independent");
|
||||||
|
assert.equal(resolved.model, "inherit");
|
||||||
|
assert.equal(resolved.thinking, "low");
|
||||||
|
assert.equal(resolved.tools, "local-review");
|
||||||
|
assert.deepEqual(resolved.toolProfile.activeTools, ["read"]);
|
||||||
|
assert.equal(resolved.agentBody, "agent body");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("built-in tool profile names cannot be overridden", () => {
|
||||||
|
const { cwd, agentDir } = fixture();
|
||||||
|
writeFileSync(join(agentDir, "subagents.json"), JSON.stringify({ toolProfiles: { "read-only": { activeTools: ["bash"] } } }));
|
||||||
|
const diag = diagnostics();
|
||||||
|
|
||||||
|
const config = loadConfig(cwd, true, diag, agentDir);
|
||||||
|
|
||||||
|
assert.deepEqual(config.toolProfiles["read-only"], BUILT_IN_TOOL_PROFILES["read-only"]);
|
||||||
|
assert.ok(diag.warnings.some((warning) => warning.includes("Ignoring global override for built-in tool profile 'read-only'")));
|
||||||
|
});
|
||||||
182
modules/agents/pi/extensions/subagents/config.ts
Normal file
182
modules/agents/pi/extensions/subagents/config.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { ContextMode, SpawnRequest, ToolProfile } from "./types.ts";
|
||||||
|
import type { AgentDefinition } from "./agents.ts";
|
||||||
|
|
||||||
|
export interface Diagnostics {
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentsConfig {
|
||||||
|
defaultContext: ContextMode;
|
||||||
|
defaultTools: string;
|
||||||
|
maxConcurrent: number;
|
||||||
|
recentTerminalTtlMs: number;
|
||||||
|
ui: {
|
||||||
|
enabled: boolean;
|
||||||
|
defaultExpanded: boolean;
|
||||||
|
};
|
||||||
|
toolProfiles: Record<string, ToolProfile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedSpawnRequest extends SpawnRequest {
|
||||||
|
prompt: string;
|
||||||
|
context: ContextMode;
|
||||||
|
tools: string;
|
||||||
|
toolProfile: ToolProfile;
|
||||||
|
agentBody?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BUILT_IN_TOOL_PROFILES: Record<string, ToolProfile> = {
|
||||||
|
none: { activeTools: [] },
|
||||||
|
"read-only": { activeTools: ["read", "grep", "find", "ls"] },
|
||||||
|
"read-only-with-safe-bash": { activeTools: ["read", "grep", "find", "ls", "bash"] },
|
||||||
|
"full-tools": { activeTools: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: SubagentsConfig = {
|
||||||
|
defaultContext: "independent",
|
||||||
|
defaultTools: "read-only",
|
||||||
|
maxConcurrent: 3,
|
||||||
|
recentTerminalTtlMs: 5 * 60 * 1000,
|
||||||
|
ui: { enabled: true, defaultExpanded: false },
|
||||||
|
toolProfiles: { ...BUILT_IN_TOOL_PROFILES },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadConfig(cwd: string, projectTrusted: boolean, diagnostics: Diagnostics, agentDir = defaultAgentDir()): SubagentsConfig {
|
||||||
|
let config = cloneConfig(DEFAULT_CONFIG);
|
||||||
|
config = mergeConfig(config, readConfig(join(agentDir, "subagents.json"), diagnostics, "global"), diagnostics, "global");
|
||||||
|
if (projectTrusted) {
|
||||||
|
config = mergeConfig(config, readConfig(join(cwd, ".pi", "subagents.json"), diagnostics, "project"), diagnostics, "project");
|
||||||
|
}
|
||||||
|
if (!config.toolProfiles[config.defaultTools]) {
|
||||||
|
diagnostics.warnings.push(`Unknown defaultTools profile '${config.defaultTools}', using read-only`);
|
||||||
|
config.defaultTools = "read-only";
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSpawn(request: SpawnRequest, config: SubagentsConfig, agents: Map<string, AgentDefinition>): ResolvedSpawnRequest {
|
||||||
|
const prompt = typeof request.prompt === "string" ? request.prompt.trim() : "";
|
||||||
|
if (!prompt) throw new Error("prompt is required");
|
||||||
|
const agent = request.agent ? agents.get(request.agent) : undefined;
|
||||||
|
if (request.agent && !agent) throw new Error(`unknown subagent agent: ${request.agent}`);
|
||||||
|
|
||||||
|
const context = request.context ?? agent?.context ?? config.defaultContext;
|
||||||
|
if (context !== "independent" && context !== "fork") throw new Error(`unsupported context: ${context}`);
|
||||||
|
if (agent?.allowedContexts && !agent.allowedContexts.includes(context)) {
|
||||||
|
throw new Error(`agent '${agent.name}' does not allow ${context} context`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = request.tools ?? agent?.tools ?? config.defaultTools;
|
||||||
|
const toolProfile = config.toolProfiles[tools];
|
||||||
|
if (!toolProfile) throw new Error(`unknown tool profile: ${tools}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...request,
|
||||||
|
prompt,
|
||||||
|
agent: agent?.name ?? request.agent,
|
||||||
|
context,
|
||||||
|
model: request.model ?? agent?.model,
|
||||||
|
thinking: request.thinking ?? agent?.thinking,
|
||||||
|
tools,
|
||||||
|
toolProfile,
|
||||||
|
agentBody: agent?.body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfig(path: string, diagnostics: Diagnostics, label: string): Partial<SubagentsConfig> | undefined {
|
||||||
|
if (!existsSync(path)) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||||
|
return normalizeConfig(parsed, diagnostics, label);
|
||||||
|
} catch (error) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} subagents.json: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(raw: unknown, diagnostics: Diagnostics, label: string): Partial<SubagentsConfig> | undefined {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} subagents.json: root must be an object`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const input = raw as Record<string, unknown>;
|
||||||
|
const config: Partial<SubagentsConfig> = {};
|
||||||
|
if (input.defaultContext === "independent" || input.defaultContext === "fork") config.defaultContext = input.defaultContext;
|
||||||
|
else if (input.defaultContext !== undefined) diagnostics.warnings.push(`Invalid ${label} defaultContext ignored`);
|
||||||
|
if (typeof input.defaultTools === "string") config.defaultTools = input.defaultTools;
|
||||||
|
else if (input.defaultTools !== undefined) diagnostics.warnings.push(`Invalid ${label} defaultTools ignored`);
|
||||||
|
if (typeof input.maxConcurrent === "number" && Number.isInteger(input.maxConcurrent) && input.maxConcurrent > 0) config.maxConcurrent = input.maxConcurrent;
|
||||||
|
else if (input.maxConcurrent !== undefined) diagnostics.warnings.push(`Invalid ${label} maxConcurrent ignored`);
|
||||||
|
if (typeof input.recentTerminalTtlMs === "number" && Number.isInteger(input.recentTerminalTtlMs) && input.recentTerminalTtlMs >= 0) {
|
||||||
|
config.recentTerminalTtlMs = input.recentTerminalTtlMs;
|
||||||
|
} else if (input.recentTerminalTtlMs !== undefined) diagnostics.warnings.push(`Invalid ${label} recentTerminalTtlMs ignored`);
|
||||||
|
if (input.ui !== undefined) config.ui = normalizeUi(input.ui, diagnostics, label);
|
||||||
|
if (input.toolProfiles !== undefined) config.toolProfiles = normalizeProfiles(input.toolProfiles, diagnostics, label);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUi(raw: unknown, diagnostics: Diagnostics, label: string): SubagentsConfig["ui"] | undefined {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} ui ignored`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const input = raw as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
enabled: typeof input.enabled === "boolean" ? input.enabled : DEFAULT_CONFIG.ui.enabled,
|
||||||
|
defaultExpanded: typeof input.defaultExpanded === "boolean" ? input.defaultExpanded : DEFAULT_CONFIG.ui.defaultExpanded,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProfiles(raw: unknown, diagnostics: Diagnostics, label: string): Record<string, ToolProfile> {
|
||||||
|
const profiles: Record<string, ToolProfile> = {};
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} toolProfiles ignored`);
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
for (const [name, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
if (name in BUILT_IN_TOOL_PROFILES) {
|
||||||
|
diagnostics.warnings.push(`Ignoring ${label} override for built-in tool profile '${name}'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const profile = normalizeProfile(value);
|
||||||
|
if (!profile) {
|
||||||
|
diagnostics.warnings.push(`Invalid ${label} tool profile '${name}' ignored`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
profiles[name] = profile;
|
||||||
|
}
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProfile(raw: unknown): ToolProfile | undefined {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||||
|
const activeTools = (raw as { activeTools?: unknown }).activeTools;
|
||||||
|
if (!Array.isArray(activeTools) || !activeTools.every((tool) => typeof tool === "string")) return undefined;
|
||||||
|
return { activeTools };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeConfig(base: SubagentsConfig, override: Partial<SubagentsConfig> | undefined, diagnostics: Diagnostics, label: string): SubagentsConfig {
|
||||||
|
if (!override) return base;
|
||||||
|
const merged = cloneConfig(base);
|
||||||
|
if (override.defaultContext) merged.defaultContext = override.defaultContext;
|
||||||
|
if (override.defaultTools) merged.defaultTools = override.defaultTools;
|
||||||
|
if (override.maxConcurrent) merged.maxConcurrent = override.maxConcurrent;
|
||||||
|
if (override.recentTerminalTtlMs !== undefined) merged.recentTerminalTtlMs = override.recentTerminalTtlMs;
|
||||||
|
if (override.ui) merged.ui = { ...merged.ui, ...override.ui };
|
||||||
|
if (override.toolProfiles) merged.toolProfiles = { ...merged.toolProfiles, ...override.toolProfiles };
|
||||||
|
for (const key of Object.keys(merged.toolProfiles)) {
|
||||||
|
if (key in BUILT_IN_TOOL_PROFILES) merged.toolProfiles[key] = BUILT_IN_TOOL_PROFILES[key];
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneConfig(config: SubagentsConfig): SubagentsConfig {
|
||||||
|
return { ...config, ui: { ...config.ui }, toolProfiles: { ...config.toolProfiles } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultAgentDir(): string {
|
||||||
|
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
||||||
|
}
|
||||||
326
modules/agents/pi/extensions/subagents/index.ts
Normal file
326
modules/agents/pi/extensions/subagents/index.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { Type } from "typebox";
|
||||||
|
import { loadAgents } from "./agents.ts";
|
||||||
|
import { loadConfig, resolveSpawn, type Diagnostics } from "./config.ts";
|
||||||
|
import { SubprocessRpcRunner } from "./runner.ts";
|
||||||
|
import { Supervisor } from "./supervisor.ts";
|
||||||
|
import { milestoneNotification } from "./status.ts";
|
||||||
|
import type { SpawnRequest, SubagentStatus } from "./types.ts";
|
||||||
|
import { widget } from "./ui.ts";
|
||||||
|
|
||||||
|
let supervisor: Supervisor | undefined;
|
||||||
|
let lastDiagnostics: Diagnostics = { warnings: [] };
|
||||||
|
let lastStatuses: SubagentStatus[] = [];
|
||||||
|
let uiExpanded = false;
|
||||||
|
|
||||||
|
export default function subagents(pi: ExtensionAPI) {
|
||||||
|
const getSupervisor = (ctx: ExtensionContext): Supervisor => {
|
||||||
|
if (supervisor) return supervisor;
|
||||||
|
const diagnostics: Diagnostics = { warnings: [] };
|
||||||
|
const cwd = cwdOf(ctx);
|
||||||
|
const config = loadConfig(cwd, isProjectTrusted(ctx), diagnostics);
|
||||||
|
lastDiagnostics = diagnostics;
|
||||||
|
uiExpanded = config.ui.defaultExpanded;
|
||||||
|
supervisor = new Supervisor(new SubprocessRpcRunner(), cwd, {
|
||||||
|
maxConcurrent: config.maxConcurrent,
|
||||||
|
recentTerminalTtlMs: config.recentTerminalTtlMs,
|
||||||
|
onMilestone: (status, event) => {
|
||||||
|
pi.appendEntry("subagent_milestone", { event, status });
|
||||||
|
const notification = milestoneNotification(status, event);
|
||||||
|
if (notification) ctx.ui?.notify?.(notification.message, notification.level);
|
||||||
|
},
|
||||||
|
onChange: (statuses) => {
|
||||||
|
lastStatuses = statuses;
|
||||||
|
updateUi(ctx, config.ui.enabled);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
updateUi(ctx, config.ui.enabled);
|
||||||
|
return supervisor;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolve = (ctx: ExtensionContext, request: SpawnRequest): SpawnRequest => {
|
||||||
|
const diagnostics: Diagnostics = { warnings: [] };
|
||||||
|
const cwd = cwdOf(ctx);
|
||||||
|
const trusted = isProjectTrusted(ctx);
|
||||||
|
const config = loadConfig(cwd, trusted, diagnostics);
|
||||||
|
const agents = loadAgents(cwd, trusted, diagnostics);
|
||||||
|
lastDiagnostics = diagnostics;
|
||||||
|
const resolved = resolveSpawn(request, config, agents);
|
||||||
|
if (resolved.context === "fork") resolved.parentSessionFile = ctx.sessionManager.getSessionFile();
|
||||||
|
return resolved;
|
||||||
|
};
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_spawn",
|
||||||
|
label: "Spawn subagent",
|
||||||
|
description: "Start one ad hoc independent subagent and return immediately with its child id",
|
||||||
|
parameters: Type.Object({
|
||||||
|
prompt: Type.String({ description: "Prompt for the delegated subagent" }),
|
||||||
|
label: Type.Optional(Type.String({ description: "Human-readable label for this work item" })),
|
||||||
|
agent: Type.Optional(Type.String({ description: "Named agent definition to use" })),
|
||||||
|
context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])),
|
||||||
|
model: Type.Optional(Type.String({ description: "Optional model selector for the child" })),
|
||||||
|
thinking: Type.Optional(Type.String({ description: "Optional thinking level for the child" })),
|
||||||
|
tools: Type.Optional(Type.String({ description: "Tool profile name" })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const accepted = getSupervisor(ctx).spawn(resolve(ctx, params as SpawnRequest));
|
||||||
|
ctx.ui?.notify?.(`Started subagent ${accepted.label}`, "info");
|
||||||
|
return textResult(accepted);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_batch",
|
||||||
|
label: "Spawn subagent batch",
|
||||||
|
description: "Start multiple subagents and return immediately with accepted child ids and per-entry failures",
|
||||||
|
parameters: Type.Object({
|
||||||
|
subagents: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
prompt: Type.String({ description: "Prompt for the delegated subagent" }),
|
||||||
|
label: Type.Optional(Type.String({ description: "Human-readable label for this work item" })),
|
||||||
|
agent: Type.Optional(Type.String({ description: "Named agent definition to use" })),
|
||||||
|
context: Type.Optional(Type.Union([Type.Literal("independent"), Type.Literal("fork")])),
|
||||||
|
model: Type.Optional(Type.String({ description: "Optional model selector for the child" })),
|
||||||
|
thinking: Type.Optional(Type.String({ description: "Optional thinking level for the child" })),
|
||||||
|
tools: Type.Optional(Type.String({ description: "Tool profile name" })),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const requests = Array.isArray((params as { subagents?: unknown }).subagents) ? ((params as { subagents: SpawnRequest[] }).subagents) : [];
|
||||||
|
const accepted: SpawnRequest[] = [];
|
||||||
|
const failed: Array<{ index: number; error: string }> = [];
|
||||||
|
requests.forEach((request, index) => {
|
||||||
|
try {
|
||||||
|
accepted.push(resolve(ctx, request));
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({ index, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = getSupervisor(ctx).spawnBatch(accepted);
|
||||||
|
return textResult({ accepted: result.accepted, failed: [...failed, ...result.failed] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_list",
|
||||||
|
label: "List subagents",
|
||||||
|
description: "List active and terminal subagents for this parent session until terminal entries are cleared",
|
||||||
|
parameters: Type.Object({}),
|
||||||
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||||
|
return textResult(getSupervisor(ctx).list());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_status",
|
||||||
|
label: "Get subagent status",
|
||||||
|
description: "Get current lifecycle status for one subagent",
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: "Subagent id returned by subagent_spawn" }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
return textResult(getSupervisor(ctx).status(String((params as { id: unknown }).id)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_result",
|
||||||
|
label: "Get subagent result",
|
||||||
|
description: "Return still-running before completion and the final answer after completion",
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: "Subagent id returned by subagent_spawn" }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
return textResult(getSupervisor(ctx).result(String((params as { id: unknown }).id)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_wait",
|
||||||
|
label: "Wait for subagents",
|
||||||
|
description: "Block until multiple subagents are terminal or a timeout expires. Prefer setting timeoutMs so the parent turn cannot hang forever",
|
||||||
|
parameters: Type.Object({
|
||||||
|
ids: Type.Array(Type.String({ description: "Subagent id returned by subagent_spawn or subagent_batch" })),
|
||||||
|
timeoutMs: Type.Optional(Type.Number({ description: "Maximum milliseconds to wait. Omit or use 0 to wait indefinitely" })),
|
||||||
|
mode: Type.Optional(Type.Union([Type.Literal("all"), Type.Literal("any")], { description: "Wait for all ids by default, or return after any id is terminal" })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||||
|
const input = params as { ids?: unknown; timeoutMs?: unknown; mode?: unknown };
|
||||||
|
const ids = Array.isArray(input.ids) ? input.ids.map(String) : [];
|
||||||
|
const timeoutMs = typeof input.timeoutMs === "number" && Number.isFinite(input.timeoutMs) ? input.timeoutMs : undefined;
|
||||||
|
const mode = input.mode === "any" ? "any" : "all";
|
||||||
|
return textResult(await getSupervisor(ctx).wait(ids, { timeoutMs, mode, signal }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_cancel",
|
||||||
|
label: "Cancel subagent",
|
||||||
|
description: "Cancel a running subagent",
|
||||||
|
parameters: Type.Object({
|
||||||
|
id: Type.String({ description: "Subagent id returned by subagent_spawn" }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
return textResult(await getSupervisor(ctx).cancel(String((params as { id: unknown }).id)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "subagent_clear",
|
||||||
|
label: "Clear terminal subagents",
|
||||||
|
description: "Remove terminal subagents from the current-session visible work set. Omitting ids clears all terminal children",
|
||||||
|
parameters: Type.Object({
|
||||||
|
ids: Type.Optional(Type.Array(Type.String({ description: "Subagent id returned by subagent_spawn or subagent_batch" }))),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const input = params as { ids?: unknown };
|
||||||
|
const ids = Array.isArray(input.ids) ? input.ids.map(String) : undefined;
|
||||||
|
return textResult({ cleared: getSupervisor(ctx).clearTerminal(ids) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-spawn", {
|
||||||
|
description: "Start an ad hoc independent subagent",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const accepted = getSupervisor(ctx).spawn(resolve(ctx, parseSpawnArgs(args)));
|
||||||
|
ctx.ui.notify(`Started subagent ${accepted.label}`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-batch", {
|
||||||
|
description: "Start ad hoc independent subagents split by |",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const requests = args
|
||||||
|
.split("|")
|
||||||
|
.map((prompt) => prompt.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((prompt) => resolve(ctx, { prompt }));
|
||||||
|
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).spawnBatch(requests), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-list", {
|
||||||
|
description: "Show subagent status records",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).list(), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-clear", {
|
||||||
|
description: "Clear terminal subagent records. Pass ids to clear selected terminal records only",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const ids = args.trim().split(/\s+/u).filter(Boolean);
|
||||||
|
ctx.ui.notify(JSON.stringify({ cleared: getSupervisor(ctx).clearTerminal(ids.length > 0 ? ids : undefined) }, null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-status", {
|
||||||
|
description: "Show a subagent status by id",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).status(args.trim()), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-result", {
|
||||||
|
description: "Show a subagent result by id",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(getSupervisor(ctx).result(args.trim()), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-wait", {
|
||||||
|
description: "Wait for subagent ids separated by spaces",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const { ids, timeoutMs, mode } = parseWaitArgs(args);
|
||||||
|
ctx.ui.notify(JSON.stringify(await getSupervisor(ctx).wait(ids, { timeoutMs, mode }), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-ui", {
|
||||||
|
description: "Toggle the bundled subagent status inspector",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
uiExpanded = !uiExpanded;
|
||||||
|
updateUi(ctx, true);
|
||||||
|
ctx.ui.notify(`Subagent inspector ${uiExpanded ? "expanded" : "collapsed"}`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-diagnostics", {
|
||||||
|
description: "Show subagent configuration diagnostics from the last load",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(lastDiagnostics, null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerCommand("subagent-cancel", {
|
||||||
|
description: "Cancel a running subagent by id",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
ctx.ui.notify(JSON.stringify(await getSupervisor(ctx).cancel(args.trim()), null, 2), "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", async () => {
|
||||||
|
await supervisor?.shutdown();
|
||||||
|
supervisor = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUi(ctx: ExtensionContext, enabled: boolean) {
|
||||||
|
if (!ctx.hasUI) return;
|
||||||
|
ctx.ui.setWidget("subagents", enabled ? widget(lastStatuses, uiExpanded) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSpawnArgs(args: string): SpawnRequest {
|
||||||
|
const parts = args.trim().split(/\s+/u);
|
||||||
|
const request: Partial<SpawnRequest> = {};
|
||||||
|
while (parts.length >= 2 && parts[0].startsWith("--")) {
|
||||||
|
const flag = parts.shift();
|
||||||
|
const value = parts.shift();
|
||||||
|
if (flag === "--agent") request.agent = value;
|
||||||
|
else if (flag === "--label") request.label = value;
|
||||||
|
else if (flag === "--context" && (value === "independent" || value === "fork")) request.context = value;
|
||||||
|
else if (flag === "--tools") request.tools = value;
|
||||||
|
else if (flag === "--model") request.model = value;
|
||||||
|
else if (flag === "--thinking") request.thinking = value;
|
||||||
|
}
|
||||||
|
return { ...request, prompt: parts.join(" ") || args } as SpawnRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWaitArgs(args: string): { ids: string[]; timeoutMs?: number; mode?: "all" | "any" } {
|
||||||
|
const parts = args.trim().split(/\s+/u).filter(Boolean);
|
||||||
|
let timeoutMs: number | undefined;
|
||||||
|
let mode: "all" | "any" | undefined;
|
||||||
|
const ids: string[] = [];
|
||||||
|
while (parts.length > 0) {
|
||||||
|
const part = parts.shift();
|
||||||
|
if (!part) continue;
|
||||||
|
if (part === "--timeout-ms" && parts[0]) {
|
||||||
|
const parsed = Number(parts.shift());
|
||||||
|
if (Number.isFinite(parsed)) timeoutMs = parsed;
|
||||||
|
} else if (part === "--mode" && (parts[0] === "all" || parts[0] === "any")) {
|
||||||
|
mode = parts.shift() as "all" | "any";
|
||||||
|
} else {
|
||||||
|
ids.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ids, timeoutMs, mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProjectTrusted(ctx: ExtensionContext): boolean {
|
||||||
|
const value = (ctx as unknown as { isProjectTrusted?: () => boolean }).isProjectTrusted?.();
|
||||||
|
return value === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cwdOf(ctx: ExtensionContext): string {
|
||||||
|
const sessionCwd = (ctx as unknown as { sessionManager?: { getCwd?: () => string }; cwd?: string }).sessionManager?.getCwd?.();
|
||||||
|
return sessionCwd ?? (ctx as unknown as { cwd?: string }).cwd ?? process.cwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResult(value: unknown) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
|
||||||
|
details: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
118
modules/agents/pi/extensions/subagents/runner.test.ts
Normal file
118
modules/agents/pi/extensions/subagents/runner.test.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import childProcess from "node:child_process";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import test from "node:test";
|
||||||
|
import type { RunnerEvents } from "./types.ts";
|
||||||
|
|
||||||
|
class FakeStream extends EventEmitter {
|
||||||
|
setEncoding(_encoding: BufferEncoding): void {}
|
||||||
|
|
||||||
|
write(_chunk: string, callback?: (error?: Error | null) => void): boolean {
|
||||||
|
callback?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
end(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function events(): RunnerEvents {
|
||||||
|
return {
|
||||||
|
accepted: () => {},
|
||||||
|
running: () => {},
|
||||||
|
settling: () => {},
|
||||||
|
completed: () => {},
|
||||||
|
failed: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("child RPC process forwards structured activity before collecting the final result", async (t) => {
|
||||||
|
const running: unknown[] = [];
|
||||||
|
const completed: Array<{ result: string; stopReason?: string }> = [];
|
||||||
|
const fakeChild = new EventEmitter() as EventEmitter & {
|
||||||
|
stdout: FakeStream;
|
||||||
|
stderr: FakeStream;
|
||||||
|
stdin: FakeStream;
|
||||||
|
killed: boolean;
|
||||||
|
pid?: number;
|
||||||
|
kill(signal?: NodeJS.Signals): boolean;
|
||||||
|
};
|
||||||
|
fakeChild.stdout = new FakeStream();
|
||||||
|
fakeChild.stderr = new FakeStream();
|
||||||
|
fakeChild.stdin = new FakeStream();
|
||||||
|
fakeChild.killed = false;
|
||||||
|
fakeChild.kill = () => {
|
||||||
|
fakeChild.killed = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
t.mock.method(fakeChild.stdin, "write", (chunk, callback?: (error?: Error | null) => void) => {
|
||||||
|
const request = JSON.parse(String(chunk)) as { id: string; type: string };
|
||||||
|
callback?.();
|
||||||
|
if (request.type === "get_last_assistant_text") {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
fakeChild.stdout.emit("data", `${JSON.stringify({ id: request.id, type: "response", success: true, data: { text: "final answer" } })}\n`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
t.mock.method(childProcess, "spawn", () => fakeChild as unknown as childProcess.ChildProcessWithoutNullStreams);
|
||||||
|
|
||||||
|
const { SubprocessRpcRunner } = await import("./runner.ts");
|
||||||
|
const runner = new SubprocessRpcRunner();
|
||||||
|
await runner.start("child-1", { prompt: "work", label: "Review migration" }, "/tmp", {
|
||||||
|
...events(),
|
||||||
|
running: (event) => running.push(event),
|
||||||
|
completed: (result, stopReason) => completed.push({ result, stopReason }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstActivity = { type: "message_start", role: "assistant", message: { id: "msg-1" } };
|
||||||
|
const secondActivity = { type: "tool_execution_start", tool: "read", input: { path: "runner.ts" } };
|
||||||
|
const settledActivity = { type: "agent_settled" };
|
||||||
|
fakeChild.stdout.emit("data", `${JSON.stringify(firstActivity)}\n${JSON.stringify(secondActivity)}\n${JSON.stringify(settledActivity)}\n`);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.deepEqual(running, [firstActivity, secondActivity, settledActivity]);
|
||||||
|
assert.deepEqual(completed, [{ result: "final answer", stopReason: "agent_settled" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("child RPC process disables discovery while explicitly loading subagents extension", async (t) => {
|
||||||
|
const calls: Array<{ command: string; args: string[] }> = [];
|
||||||
|
const fakeChild = new EventEmitter() as EventEmitter & {
|
||||||
|
stdout: FakeStream;
|
||||||
|
stderr: FakeStream;
|
||||||
|
stdin: FakeStream;
|
||||||
|
killed: boolean;
|
||||||
|
pid?: number;
|
||||||
|
kill(signal?: NodeJS.Signals): boolean;
|
||||||
|
};
|
||||||
|
fakeChild.stdout = new FakeStream();
|
||||||
|
fakeChild.stderr = new FakeStream();
|
||||||
|
fakeChild.stdin = new FakeStream();
|
||||||
|
fakeChild.killed = false;
|
||||||
|
fakeChild.kill = () => {
|
||||||
|
fakeChild.killed = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const spawn = t.mock.method(childProcess, "spawn", (command, args) => {
|
||||||
|
calls.push({ command: String(command), args: Array.isArray(args) ? args.map(String) : [] });
|
||||||
|
return fakeChild as unknown as childProcess.ChildProcessWithoutNullStreams;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { SubprocessRpcRunner } = await import("./runner.ts");
|
||||||
|
const runner = new SubprocessRpcRunner();
|
||||||
|
await runner.start("child-1", { prompt: "work", label: "Review migration" }, "/tmp", events());
|
||||||
|
|
||||||
|
assert.equal(spawn.mock.callCount(), 1);
|
||||||
|
const args = calls[0].args;
|
||||||
|
const noExtensionsIndex = args.indexOf("--no-extensions");
|
||||||
|
const extensionIndex = args.indexOf("--extension");
|
||||||
|
|
||||||
|
const nameIndex = args.indexOf("--name");
|
||||||
|
|
||||||
|
assert.notEqual(noExtensionsIndex, -1, "child args keep automatic extension discovery disabled");
|
||||||
|
assert.notEqual(nameIndex, -1, "child args include a process name");
|
||||||
|
assert.equal(args[nameIndex + 1], "subagent Review migration");
|
||||||
|
assert.notEqual(extensionIndex, -1, "child args explicitly load the subagents extension entry");
|
||||||
|
assert.equal(args[extensionIndex + 1], fileURLToPath(new URL("./index.ts", import.meta.url)));
|
||||||
|
assert.ok(noExtensionsIndex < extensionIndex);
|
||||||
|
});
|
||||||
218
modules/agents/pi/extensions/subagents/runner.ts
Normal file
218
modules/agents/pi/extensions/subagents/runner.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts";
|
||||||
|
|
||||||
|
interface PendingResponse {
|
||||||
|
resolve(value: unknown): void;
|
||||||
|
reject(error: Error): void;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcLine {
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
command?: string;
|
||||||
|
success?: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RpcChildHandle implements ChildHandle {
|
||||||
|
private buffer = "";
|
||||||
|
private nextRequest = 0;
|
||||||
|
private settled = false;
|
||||||
|
private finishing = false;
|
||||||
|
private cancelling = false;
|
||||||
|
private killed = false;
|
||||||
|
private readonly pending = new Map<string, PendingResponse>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly child: ChildProcessWithoutNullStreams,
|
||||||
|
private readonly events: RunnerEvents,
|
||||||
|
) {
|
||||||
|
child.stdout.setEncoding("utf8");
|
||||||
|
child.stderr.setEncoding("utf8");
|
||||||
|
child.stdout.on("data", (chunk) => this.onStdout(chunk));
|
||||||
|
child.stderr.on("data", (chunk) => this.events.running(`stderr: ${String(chunk).trim().slice(0, 200)}`));
|
||||||
|
child.on("error", (error) => this.fail(error.message));
|
||||||
|
child.on("close", (code, signal) => {
|
||||||
|
for (const pending of this.pending.values()) {
|
||||||
|
pending.reject(new Error(`RPC process closed before ${pending.command} response`));
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
if (!this.settled) this.fail(`RPC process closed with code ${code ?? "null"} signal ${signal ?? "null"}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async prompt(message: string): Promise<void> {
|
||||||
|
await this.send("prompt", { message });
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(): Promise<void> {
|
||||||
|
if (this.cancelling) return;
|
||||||
|
this.cancelling = true;
|
||||||
|
try {
|
||||||
|
await Promise.race([this.send("abort", {}), delay(200)]);
|
||||||
|
} catch {}
|
||||||
|
this.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onStdout(chunk: string) {
|
||||||
|
this.buffer += chunk;
|
||||||
|
while (true) {
|
||||||
|
const newline = this.buffer.indexOf("\n");
|
||||||
|
if (newline === -1) return;
|
||||||
|
const line = this.buffer.slice(0, newline).replace(/\r$/, "");
|
||||||
|
this.buffer = this.buffer.slice(newline + 1);
|
||||||
|
if (line.trim() === "") continue;
|
||||||
|
this.onLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onLine(line: string) {
|
||||||
|
let payload: RpcLine;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
this.events.running(`non-json rpc output: ${line.slice(0, 200)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "response" && payload.id) {
|
||||||
|
const pending = this.pending.get(payload.id);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pending.delete(payload.id);
|
||||||
|
if (payload.success) pending.resolve(payload.data);
|
||||||
|
else pending.reject(new Error(payload.error ?? payload.message ?? `${pending.command} failed`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "agent_started") {
|
||||||
|
this.events.running(payload as Record<string, unknown>);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === "agent_settled") {
|
||||||
|
this.events.running(payload as Record<string, unknown>);
|
||||||
|
this.finish().catch((error) => this.fail(error instanceof Error ? error.message : String(error)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type) this.events.running(payload as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async finish() {
|
||||||
|
if (this.settled || this.finishing) return;
|
||||||
|
this.finishing = true;
|
||||||
|
this.events.settling();
|
||||||
|
const result = await this.send("get_last_assistant_text", {});
|
||||||
|
const text = typeof result === "string" ? result : result && typeof result === "object" && "text" in result ? String((result as { text: unknown }).text) : "";
|
||||||
|
this.settled = true;
|
||||||
|
this.events.completed(text, "agent_settled");
|
||||||
|
this.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private terminate() {
|
||||||
|
if (this.killed) return;
|
||||||
|
this.killed = true;
|
||||||
|
this.child.stdin.end();
|
||||||
|
if (this.child.killed) return;
|
||||||
|
if (process.platform !== "win32" && this.child.pid) {
|
||||||
|
try {
|
||||||
|
process.kill(-this.child.pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
this.child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.child.killed || !this.child.pid) return;
|
||||||
|
try {
|
||||||
|
process.kill(-this.child.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
this.child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
}, 2_000).unref();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.child.kill("SIGTERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(error: string) {
|
||||||
|
if (this.settled) return;
|
||||||
|
this.settled = true;
|
||||||
|
this.events.failed(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(command: string, body: Record<string, unknown>): Promise<unknown> {
|
||||||
|
const id = `subagent-${++this.nextRequest}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, { resolve, reject, command });
|
||||||
|
this.child.stdin.write(`${JSON.stringify({ id, type: command, ...body })}\n`, (error) => {
|
||||||
|
if (!error) return;
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubprocessRpcRunner implements ChildRunner {
|
||||||
|
async start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise<ChildHandle> {
|
||||||
|
const args = [process.argv[1], "--mode", "rpc", "--no-extensions", "--extension", subagentsExtensionPath(), "--name", `subagent ${request.label ?? id}`, ...contextArgs(request), ...toolArgs(request), ...modelArgs(request)];
|
||||||
|
const child = spawn(process.execPath, args, {
|
||||||
|
cwd,
|
||||||
|
env: childEnvironment(),
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
detached: process.platform !== "win32",
|
||||||
|
});
|
||||||
|
const handle = new RpcChildHandle(child, events);
|
||||||
|
events.accepted();
|
||||||
|
void handle.prompt(independentPrompt(request)).catch((error) => events.failed(error instanceof Error ? error.message : String(error)));
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentsExtensionPath(): string {
|
||||||
|
return fileURLToPath(new URL("./index.ts", import.meta.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextArgs(request: SpawnRequest): string[] {
|
||||||
|
if (request.context !== "fork" || !request.parentSessionFile) return [];
|
||||||
|
return ["--fork", request.parentSessionFile];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolArgs(request: SpawnRequest): string[] {
|
||||||
|
const activeTools = request.toolProfile?.activeTools;
|
||||||
|
if (activeTools === undefined || activeTools === null) return [];
|
||||||
|
if (activeTools.length === 0) return ["--no-tools"];
|
||||||
|
return ["--tools", activeTools.join(",")];
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelArgs(request: SpawnRequest): string[] {
|
||||||
|
const args: string[] = [];
|
||||||
|
if (request.model && request.model !== "inherit") args.push("--model", request.model);
|
||||||
|
if (request.thinking) args.push("--thinking", request.thinking);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function childEnvironment(): NodeJS.ProcessEnv {
|
||||||
|
const env = { ...process.env };
|
||||||
|
delete env.PI_SESSION_ID;
|
||||||
|
delete env.PI_SESSION_FILE;
|
||||||
|
delete env.PI_PROVIDER;
|
||||||
|
delete env.PI_MODEL;
|
||||||
|
delete env.PI_REASONING_LEVEL;
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function independentPrompt(request: SpawnRequest): string {
|
||||||
|
const base = request.agentBody ? `${request.agentBody}\n\n` : "";
|
||||||
|
if (request.context === "fork") {
|
||||||
|
return `${base}You are running as a delegated subagent in fork context.\nUse the inherited parent session context, then return a concise final answer for the parent agent.\n\nTask:\n${request.prompt}`;
|
||||||
|
}
|
||||||
|
return `${base}You are running as a delegated subagent in independent context.\nDo not assume access to the parent conversation transcript.\nReturn a concise final answer for the parent agent.\n\nTask:\n${request.prompt}`;
|
||||||
|
}
|
||||||
58
modules/agents/pi/extensions/subagents/status.ts
Normal file
58
modules/agents/pi/extensions/subagents/status.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { SUBAGENT_STATES, SUBAGENT_TERMINAL_STATES } from "./types.ts";
|
||||||
|
import type { ChildRecord, SpawnAccepted, SubagentResult, SubagentState, SubagentStatus } from "./types.ts";
|
||||||
|
|
||||||
|
export function toAccepted(status: SubagentStatus): SpawnAccepted {
|
||||||
|
return {
|
||||||
|
id: status.id,
|
||||||
|
label: status.label,
|
||||||
|
context: status.context,
|
||||||
|
tools: status.tools,
|
||||||
|
state: status.state,
|
||||||
|
hint: `Use subagent_status or subagent_result with id ${status.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneStatus(status: SubagentStatus): SubagentStatus {
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
currentActivity: status.currentActivity ? { ...status.currentActivity } : undefined,
|
||||||
|
activityHistory: status.activityHistory.map((event) => ({ ...event })),
|
||||||
|
elapsedMs: elapsedMs(status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneResult(record: ChildRecord): SubagentResult {
|
||||||
|
const status = cloneStatus(record.status);
|
||||||
|
const terminal = isTerminalState(status.state);
|
||||||
|
return {
|
||||||
|
id: status.id,
|
||||||
|
label: status.label,
|
||||||
|
state: status.state,
|
||||||
|
running: !terminal,
|
||||||
|
resultAvailable: status.resultAvailable,
|
||||||
|
result: record.result,
|
||||||
|
error: status.error,
|
||||||
|
completedAt: status.completedAt,
|
||||||
|
elapsedMs: status.elapsedMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTerminalState(state: SubagentState): boolean {
|
||||||
|
return (SUBAGENT_TERMINAL_STATES as readonly string[]).includes(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function milestoneNotification(status: SubagentStatus, event: string): { message: string; level: "info" | "error" } | undefined {
|
||||||
|
if (!isSubagentState(event) || !isTerminalState(event)) return undefined;
|
||||||
|
return { message: `Subagent ${status.label} ${event}`, level: event === "completed" ? "info" : "error" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSubagentState(value: string): value is SubagentState {
|
||||||
|
return (SUBAGENT_STATES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function elapsedMs(status: Pick<SubagentStatus, "startedAt" | "completedAt">): number {
|
||||||
|
const start = Date.parse(status.startedAt);
|
||||||
|
const end = status.completedAt ? Date.parse(status.completedAt) : Date.now();
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end)) return 0;
|
||||||
|
return Math.max(0, end - start);
|
||||||
|
}
|
||||||
469
modules/agents/pi/extensions/subagents/supervisor.test.ts
Normal file
469
modules/agents/pi/extensions/subagents/supervisor.test.ts
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { milestoneNotification } from "./status.ts";
|
||||||
|
import { Supervisor } from "./supervisor.ts";
|
||||||
|
import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts";
|
||||||
|
import { widget } from "./ui.ts";
|
||||||
|
|
||||||
|
class FakeHandle implements ChildHandle {
|
||||||
|
cancelCalls = 0;
|
||||||
|
|
||||||
|
async cancel(): Promise<void> {
|
||||||
|
this.cancelCalls += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeRunner implements ChildRunner {
|
||||||
|
starts: Array<{ id: string; request: SpawnRequest; events: RunnerEvents; handle: FakeHandle }> = [];
|
||||||
|
autoAccept = true;
|
||||||
|
|
||||||
|
async start(id: string, request: SpawnRequest, _cwd: string, events: RunnerEvents): Promise<ChildHandle> {
|
||||||
|
const handle = new FakeHandle();
|
||||||
|
this.starts.push({ id, request, events, handle });
|
||||||
|
if (this.autoAccept) events.accepted(`session-${id}`);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
async function spawnStarted(supervisor: Supervisor, prompt = "work") {
|
||||||
|
const accepted = supervisor.spawn({ prompt });
|
||||||
|
await sleep(0);
|
||||||
|
return accepted;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("cancel is idempotent and reaches cancelled", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
const first = await supervisor.cancel(accepted.id);
|
||||||
|
const second = await supervisor.cancel(accepted.id);
|
||||||
|
|
||||||
|
assert.equal(first.state, "cancelled");
|
||||||
|
assert.equal(second.state, "cancelled");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("startup timeout reaches timed_out", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
runner.autoAccept = false;
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { timeouts: { startMs: 5 } });
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
await sleep(20);
|
||||||
|
|
||||||
|
const status = supervisor.status(accepted.id);
|
||||||
|
assert.equal(status.state, "timed_out");
|
||||||
|
assert.equal(status.stopReason, "start_timeout");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runtime timeout reaches timed_out", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { timeouts: { runMs: 5 } });
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
await sleep(20);
|
||||||
|
|
||||||
|
const status = supervisor.status(accepted.id);
|
||||||
|
assert.equal(status.state, "timed_out");
|
||||||
|
assert.equal(status.stopReason, "run_timeout");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("activity exposes ordered transcript events while status and list keep only summaries", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
runner.starts[0].events.running({ type: "message_started", role: "assistant" });
|
||||||
|
runner.starts[0].events.running({
|
||||||
|
type: "message_delta",
|
||||||
|
role: "assistant",
|
||||||
|
assistantMessageEvent: { type: "content_delta", delta: "private transcript body" },
|
||||||
|
});
|
||||||
|
runner.starts[0].events.running({ type: "tool_started", tool: "read", input: { path: "secret-notes.md" } });
|
||||||
|
runner.starts[0].events.running({ type: "tool_completed", tool: "read", output: "secret file contents" });
|
||||||
|
|
||||||
|
type ActivityStatus = ReturnType<Supervisor["status"]> & {
|
||||||
|
activityHistory: Array<{ type: string; summary: string }>;
|
||||||
|
currentActivity: { summary: string };
|
||||||
|
};
|
||||||
|
const activity = supervisor.activity(accepted.id);
|
||||||
|
const status = supervisor.status(accepted.id) as ActivityStatus;
|
||||||
|
const listed = supervisor.list().find((item) => item.id === accepted.id) as ActivityStatus | undefined;
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
activity.map((event) => event.type),
|
||||||
|
["queued", "starting", "prompt accepted", "message_started", "message_delta", "tool_started", "tool_completed"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(activity[4], {
|
||||||
|
type: "message_delta",
|
||||||
|
summary: "assistant message content_delta",
|
||||||
|
at: activity[4].at,
|
||||||
|
role: "assistant",
|
||||||
|
tool: undefined,
|
||||||
|
phase: "content_delta",
|
||||||
|
text: "private transcript body",
|
||||||
|
input: undefined,
|
||||||
|
output: undefined,
|
||||||
|
error: undefined,
|
||||||
|
payload: {
|
||||||
|
type: "message_delta",
|
||||||
|
role: "assistant",
|
||||||
|
assistantMessageEvent: { type: "content_delta", delta: "private transcript body" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(activity[5], {
|
||||||
|
type: "tool_started",
|
||||||
|
summary: "read secret-notes.md",
|
||||||
|
at: activity[5].at,
|
||||||
|
role: undefined,
|
||||||
|
tool: "read",
|
||||||
|
phase: "started",
|
||||||
|
text: undefined,
|
||||||
|
input: { path: "secret-notes.md" },
|
||||||
|
output: undefined,
|
||||||
|
error: undefined,
|
||||||
|
payload: { type: "tool_started", tool: "read", input: { path: "secret-notes.md" } },
|
||||||
|
});
|
||||||
|
assert.equal(activity[6].output, "secret file contents");
|
||||||
|
|
||||||
|
assert.ok(Array.isArray(status.activityHistory), "status should expose structured activityHistory");
|
||||||
|
assert.deepEqual(status.activityHistory.map((event) => event.type), activity.map((event) => event.type));
|
||||||
|
assert.deepEqual(status.activityHistory.map((event) => event.summary), activity.map((event) => event.summary));
|
||||||
|
assert.equal(status.currentActivity.summary, "read");
|
||||||
|
assert.equal(listed?.currentActivity.summary, "read");
|
||||||
|
assert.doesNotMatch(JSON.stringify(status), /private transcript body|secret file contents/u);
|
||||||
|
assert.doesNotMatch(JSON.stringify(listed), /private transcript body|secret file contents/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("status activity history keeps only the 100 most recent summaries", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
for (let index = 0; index < 150; index += 1) {
|
||||||
|
runner.starts[0].events.running(`tick ${index}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = supervisor.status(accepted.id).activityHistory;
|
||||||
|
|
||||||
|
assert.equal(history.length, 100);
|
||||||
|
assert.equal(history[0].summary, "tick 50");
|
||||||
|
assert.equal(history[99].summary, "tick 149");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("process failure reaches failed with diagnostics", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
runner.starts[0].events.failed("process closed with code 1");
|
||||||
|
|
||||||
|
const status = supervisor.status(accepted.id);
|
||||||
|
assert.equal(status.state, "failed");
|
||||||
|
assert.equal(status.error, "process closed with code 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shutdown cancels running children", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
await supervisor.shutdown();
|
||||||
|
|
||||||
|
const status = supervisor.status(accepted.id);
|
||||||
|
assert.equal(status.state, "cancelled");
|
||||||
|
assert.equal(status.stopReason, "shutdown");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("completed children ignore later cancel", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
await supervisor.cancel(accepted.id);
|
||||||
|
|
||||||
|
const result = supervisor.result(accepted.id);
|
||||||
|
assert.equal(result.state, "completed");
|
||||||
|
assert.equal(result.result, "done");
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("explicit labels are reused across accepted status list and result surfaces", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const label = "Review risky migration";
|
||||||
|
|
||||||
|
const accepted = supervisor.spawn({ prompt: "inspect the migration plan", label } as SpawnRequest & { label: string });
|
||||||
|
await sleep(0);
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
accepted: accepted.label,
|
||||||
|
status: supervisor.status(accepted.id).label,
|
||||||
|
list: supervisor.list().find((status) => status.id === accepted.id)?.label,
|
||||||
|
result: (supervisor.result(accepted.id) as { label?: string }).label,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accepted: label,
|
||||||
|
status: label,
|
||||||
|
list: label,
|
||||||
|
result: label,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ad hoc fallback labels are prompt-derived and reused by widget and result surfaces", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const prompt = " Audit\n\tguest enablement plan ";
|
||||||
|
const label = "Audit guest enablement plan";
|
||||||
|
|
||||||
|
const accepted = supervisor.spawn({ prompt });
|
||||||
|
await sleep(0);
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
const statuses = supervisor.list();
|
||||||
|
const inspectorLines = widget(statuses, true)().render(240);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
{
|
||||||
|
accepted: accepted.label,
|
||||||
|
childRequest: runner.starts[0].request.label,
|
||||||
|
status: supervisor.status(accepted.id).label,
|
||||||
|
list: statuses.find((status) => status.id === accepted.id)?.label,
|
||||||
|
result: supervisor.result(accepted.id).label,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accepted: label,
|
||||||
|
childRequest: label,
|
||||||
|
status: label,
|
||||||
|
list: label,
|
||||||
|
result: label,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.ok(inspectorLines.some((line) => line.includes(`completed 0s ${label} result: available`)), inspectorLines.join("\n"));
|
||||||
|
assert.doesNotMatch(accepted.label, /^ad-hoc sg-/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("milestone notifications use the stored label", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = supervisor.spawn({ prompt: "work", label: "Review migration" });
|
||||||
|
await sleep(0);
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
|
||||||
|
assert.deepEqual(milestoneNotification(supervisor.status(accepted.id), "completed"), {
|
||||||
|
message: "Subagent Review migration completed",
|
||||||
|
level: "info",
|
||||||
|
});
|
||||||
|
assert.equal(milestoneNotification(supervisor.status(accepted.id), "running"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shutdown clears recent terminal expiry timer", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
let changes = 0;
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", {
|
||||||
|
recentTerminalTtlMs: 5,
|
||||||
|
onChange: () => {
|
||||||
|
changes += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
await supervisor.shutdown();
|
||||||
|
const afterShutdown = changes;
|
||||||
|
await sleep(15);
|
||||||
|
|
||||||
|
assert.equal(changes, afterShutdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("batch spawn returns explicit labels on accepted child requests and statuses while preserving failures", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
|
||||||
|
const result = supervisor.spawnBatch([
|
||||||
|
{ prompt: "one", label: "Review docs" },
|
||||||
|
{ prompt: "" },
|
||||||
|
{ prompt: "two", label: "Check tests" },
|
||||||
|
]);
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
assert.deepEqual(result.accepted.map((accepted) => accepted.label), ["Review docs", "Check tests"]);
|
||||||
|
assert.equal(result.failed.length, 1);
|
||||||
|
assert.equal(result.failed[0].index, 1);
|
||||||
|
assert.deepEqual(runner.starts.map((start) => start.request.label), ["Review docs", "Check tests"]);
|
||||||
|
assert.deepEqual(result.accepted.map((accepted) => supervisor.status(accepted.id).label), ["Review docs", "Check tests"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maxConcurrent preserves queued records", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { maxConcurrent: 1 });
|
||||||
|
|
||||||
|
const result = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "two" }]);
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
assert.equal(result.accepted.length, 2);
|
||||||
|
assert.equal(runner.starts.length, 1);
|
||||||
|
assert.equal(supervisor.status(result.accepted[1].id).state, "queued");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
assert.equal(runner.starts.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearTerminal returns only removed terminal ids", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const first = await spawnStarted(supervisor, "one");
|
||||||
|
const second = await spawnStarted(supervisor, "two");
|
||||||
|
const running = await spawnStarted(supervisor, "three");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
runner.starts[1].events.completed("two done", "agent_settled");
|
||||||
|
|
||||||
|
assert.deepEqual(supervisor.clearTerminal(), [first.id, second.id]);
|
||||||
|
assert.throws(() => supervisor.status(first.id), /unknown subagent id/);
|
||||||
|
assert.throws(() => supervisor.status(second.id), /unknown subagent id/);
|
||||||
|
assert.equal(supervisor.status(running.id).state, "running");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("terminal records expire after ttl while active children remain", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 5 });
|
||||||
|
const completed = await spawnStarted(supervisor, "one");
|
||||||
|
const failed = await spawnStarted(supervisor, "two");
|
||||||
|
const running = await spawnStarted(supervisor, "three");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
runner.starts[1].events.failed("two failed");
|
||||||
|
|
||||||
|
assert.equal(supervisor.result(completed.id).result, "one done");
|
||||||
|
assert.equal(supervisor.result(failed.id).error, "two failed");
|
||||||
|
assert.equal(supervisor.status(running.id).state, "running");
|
||||||
|
|
||||||
|
await sleep(20);
|
||||||
|
|
||||||
|
const listedIds = supervisor.list().map((status) => status.id);
|
||||||
|
assert.equal(listedIds.includes(completed.id), false);
|
||||||
|
assert.equal(listedIds.includes(failed.id), false);
|
||||||
|
assert.equal(listedIds.includes(running.id), true);
|
||||||
|
assert.throws(() => supervisor.status(completed.id), /unknown subagent id/);
|
||||||
|
assert.throws(() => supervisor.status(failed.id), /unknown subagent id/);
|
||||||
|
assert.throws(() => supervisor.result(completed.id), /unknown subagent id/);
|
||||||
|
assert.throws(() => supervisor.result(failed.id), /unknown subagent id/);
|
||||||
|
assert.equal(supervisor.status(running.id).state, "running");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero recent terminal ttl does not hide terminal statuses", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 0 });
|
||||||
|
const accepted = await spawnStarted(supervisor);
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("done", "agent_settled");
|
||||||
|
|
||||||
|
assert.equal(supervisor.list().some((status) => status.id === accepted.id), true);
|
||||||
|
assert.equal(supervisor.result(accepted.id).result, "done");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait blocks until multiple subagents are terminal", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const first = await spawnStarted(supervisor, "one");
|
||||||
|
const second = await spawnStarted(supervisor, "two");
|
||||||
|
|
||||||
|
const waiting = supervisor.wait([first.id, second.id], { timeoutMs: 100 });
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
assert.equal(await Promise.race([waiting.then(() => "done"), sleep(10).then(() => "pending")]), "pending");
|
||||||
|
|
||||||
|
runner.starts[1].events.failed("two failed");
|
||||||
|
const result = await waiting;
|
||||||
|
|
||||||
|
assert.equal(result.timedOut, false);
|
||||||
|
assert.equal(result.ready, true);
|
||||||
|
assert.deepEqual(result.ids, [first.id, second.id]);
|
||||||
|
assert.equal(result.pending.length, 0);
|
||||||
|
assert.deepEqual(result.results.map((item) => item.state), ["completed", "failed"]);
|
||||||
|
assert.equal(result.results[0].result, "one done");
|
||||||
|
assert.equal(result.results[1].error, "two failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait returns pending statuses on timeout", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const first = await spawnStarted(supervisor, "one");
|
||||||
|
const second = await spawnStarted(supervisor, "two");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
const result = await supervisor.wait([first.id, second.id], { timeoutMs: 5 });
|
||||||
|
|
||||||
|
assert.equal(result.timedOut, true);
|
||||||
|
assert.equal(result.ready, false);
|
||||||
|
assert.deepEqual(result.results.map((item) => item.state), ["completed", "running"]);
|
||||||
|
assert.deepEqual(result.pending.map((item) => item.id), [second.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait any returns after the first terminal subagent", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const first = await spawnStarted(supervisor, "one");
|
||||||
|
const second = await spawnStarted(supervisor, "two");
|
||||||
|
|
||||||
|
const waiting = supervisor.wait([first.id, second.id], { mode: "any", timeoutMs: 100 });
|
||||||
|
runner.starts[1].events.completed("two done", "agent_settled");
|
||||||
|
const result = await waiting;
|
||||||
|
|
||||||
|
assert.equal(result.timedOut, false);
|
||||||
|
assert.equal(result.ready, true);
|
||||||
|
assert.deepEqual(result.results.map((item) => item.state), ["running", "completed"]);
|
||||||
|
assert.deepEqual(result.pending.map((item) => item.id), [first.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait rejects unknown and empty id sets", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
|
||||||
|
await assert.rejects(() => supervisor.wait([]), /at least one subagent id is required/);
|
||||||
|
await assert.rejects(() => supervisor.wait(["missing"]), /unknown subagent id: missing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait abort rejects without cancelling child", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp");
|
||||||
|
const accepted = await spawnStarted(supervisor, "one");
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const waiting = supervisor.wait([accepted.id], { signal: controller.signal });
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await assert.rejects(waiting, /subagent wait aborted/);
|
||||||
|
assert.equal(runner.starts[0].handle.cancelCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wait follows queued subagents through queue start and completion", async () => {
|
||||||
|
const runner = new FakeRunner();
|
||||||
|
const supervisor = new Supervisor(runner, "/tmp", { maxConcurrent: 1 });
|
||||||
|
const batch = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "two" }]);
|
||||||
|
await sleep(0);
|
||||||
|
|
||||||
|
const waiting = supervisor.wait([batch.accepted[1].id], { timeoutMs: 100 });
|
||||||
|
assert.equal(await Promise.race([waiting.then(() => "done"), sleep(10).then(() => "pending")]), "pending");
|
||||||
|
|
||||||
|
runner.starts[0].events.completed("one done", "agent_settled");
|
||||||
|
await sleep(0);
|
||||||
|
runner.starts[1].events.completed("two done", "agent_settled");
|
||||||
|
const result = await waiting;
|
||||||
|
|
||||||
|
assert.equal(result.timedOut, false);
|
||||||
|
assert.equal(result.ready, true);
|
||||||
|
assert.deepEqual(result.results.map((item) => item.result), ["two done"]);
|
||||||
|
});
|
||||||
558
modules/agents/pi/extensions/subagents/supervisor.ts
Normal file
558
modules/agents/pi/extensions/subagents/supervisor.ts
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
import type {
|
||||||
|
ChildHandle,
|
||||||
|
ChildRecord,
|
||||||
|
ChildRunner,
|
||||||
|
ContextMode,
|
||||||
|
RunnerActivity,
|
||||||
|
RunnerEvents,
|
||||||
|
SpawnAccepted,
|
||||||
|
SpawnRequest,
|
||||||
|
SubagentResult,
|
||||||
|
SubagentStatus,
|
||||||
|
SubagentWaitMode,
|
||||||
|
SubagentWaitResult,
|
||||||
|
} from "./types.ts";
|
||||||
|
import { cloneResult, cloneStatus, isTerminalState, toAccepted } from "./status.ts";
|
||||||
|
|
||||||
|
interface RunningChild {
|
||||||
|
record: ChildRecord;
|
||||||
|
request: SpawnRequest;
|
||||||
|
handle?: ChildHandle;
|
||||||
|
startTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
runTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
expiryTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SupervisorOptions {
|
||||||
|
maxConcurrent?: number;
|
||||||
|
recentTerminalLimit?: number;
|
||||||
|
recentTerminalTtlMs?: number;
|
||||||
|
timeouts?: {
|
||||||
|
startMs?: number;
|
||||||
|
runMs?: number;
|
||||||
|
};
|
||||||
|
onMilestone?: (status: SubagentStatus, event: string) => void;
|
||||||
|
onChange?: (statuses: SubagentStatus[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchSpawnResult {
|
||||||
|
accepted: SpawnAccepted[];
|
||||||
|
failed: Array<{ index: number; error: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUTS = {
|
||||||
|
startMs: 30_000,
|
||||||
|
runMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_ACTIVITY_HISTORY = 100;
|
||||||
|
|
||||||
|
export class Supervisor {
|
||||||
|
private nextChild = 0;
|
||||||
|
private readonly children = new Map<string, RunningChild>();
|
||||||
|
private readonly queue: RunningChild[] = [];
|
||||||
|
private readonly waiters = new Set<() => void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly runner: ChildRunner,
|
||||||
|
private readonly cwd: string,
|
||||||
|
private readonly options: SupervisorOptions = {},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
spawn(request: SpawnRequest): SpawnAccepted {
|
||||||
|
return this.createChild(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnBatch(requests: SpawnRequest[]): BatchSpawnResult {
|
||||||
|
const accepted: SpawnAccepted[] = [];
|
||||||
|
const failed: Array<{ index: number; error: string }> = [];
|
||||||
|
requests.forEach((request, index) => {
|
||||||
|
try {
|
||||||
|
accepted.push(this.createChild(request));
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({ index, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { accepted, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): SubagentStatus[] {
|
||||||
|
const statuses = [...this.children.values()].map((child) => cloneStatus(child.record.status));
|
||||||
|
const active = statuses.filter((status) => !isTerminal(status.state));
|
||||||
|
const terminal = statuses
|
||||||
|
.filter((status) => isTerminal(status.state))
|
||||||
|
.sort((a, b) => Date.parse(b.completedAt ?? b.startedAt) - Date.parse(a.completedAt ?? a.startedAt));
|
||||||
|
return [...active, ...terminal];
|
||||||
|
}
|
||||||
|
|
||||||
|
status(id: string): SubagentStatus {
|
||||||
|
return cloneStatus(this.require(id).record.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
result(id: string): SubagentResult {
|
||||||
|
return cloneResult(this.require(id).record);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTerminal(ids?: string[]): string[] {
|
||||||
|
const selectedIds = ids ? [...new Set(ids.map((id) => id.trim()).filter(Boolean))] : undefined;
|
||||||
|
if (selectedIds) for (const id of selectedIds) this.require(id);
|
||||||
|
const cleared: string[] = [];
|
||||||
|
for (const [id, child] of this.children) {
|
||||||
|
if (selectedIds && !selectedIds.includes(id)) continue;
|
||||||
|
if (!isTerminal(child.record.status.state)) continue;
|
||||||
|
this.clearTimer(child, "expiryTimer");
|
||||||
|
cleared.push(id);
|
||||||
|
this.children.delete(id);
|
||||||
|
}
|
||||||
|
if (cleared.length > 0) this.emitChange();
|
||||||
|
return cleared;
|
||||||
|
}
|
||||||
|
|
||||||
|
async wait(
|
||||||
|
ids: string[],
|
||||||
|
options: { timeoutMs?: number; signal?: AbortSignal; mode?: SubagentWaitMode } = {},
|
||||||
|
): Promise<SubagentWaitResult> {
|
||||||
|
const uniqueIds = [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
|
||||||
|
if (uniqueIds.length === 0) throw new Error("at least one subagent id is required");
|
||||||
|
for (const id of uniqueIds) this.require(id);
|
||||||
|
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const mode = options.mode ?? "all";
|
||||||
|
if (mode !== "all" && mode !== "any") throw new Error(`unknown wait mode: ${mode}`);
|
||||||
|
const deadline = options.timeoutMs && options.timeoutMs > 0 ? startedAt + options.timeoutMs : undefined;
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
while (!this.waitReady(uniqueIds, mode)) {
|
||||||
|
if (options.signal?.aborted) throw new Error("subagent wait aborted");
|
||||||
|
const remainingMs = deadline === undefined ? undefined : deadline - Date.now();
|
||||||
|
if (remainingMs !== undefined && remainingMs <= 0) {
|
||||||
|
timedOut = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await this.nextChange(remainingMs, options.signal).catch((error) => {
|
||||||
|
if (error instanceof Error && error.message === "subagent wait timed out") timedOut = true;
|
||||||
|
else throw error;
|
||||||
|
});
|
||||||
|
if (timedOut) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = uniqueIds.map((id) => this.result(id));
|
||||||
|
const pending = uniqueIds
|
||||||
|
.map((id) => this.status(id))
|
||||||
|
.filter((status) => !isTerminal(status.state));
|
||||||
|
return { ids: uniqueIds, mode, ready: this.waitReady(uniqueIds, mode), results, pending, timedOut, elapsedMs: Date.now() - startedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string): Promise<SubagentStatus> {
|
||||||
|
const child = this.require(id);
|
||||||
|
if (isTerminal(child.record.status.state)) return cloneStatus(child.record.status);
|
||||||
|
await child.handle?.cancel();
|
||||||
|
this.completeWithoutResult(child, "cancelled", "cancelled");
|
||||||
|
this.pumpQueue();
|
||||||
|
return cloneStatus(child.record.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
await Promise.allSettled(
|
||||||
|
[...this.children.values()].map(async (child) => {
|
||||||
|
if (!isTerminal(child.record.status.state)) {
|
||||||
|
await child.handle?.cancel();
|
||||||
|
this.completeWithoutResult(child, "cancelled", "shutdown");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const child of this.children.values()) this.clearTimer(child, "expiryTimer");
|
||||||
|
}
|
||||||
|
|
||||||
|
private createChild(request: SpawnRequest): SpawnAccepted {
|
||||||
|
const prompt = typeof request.prompt === "string" ? request.prompt.trim() : "";
|
||||||
|
if (!prompt) throw new Error("prompt is required");
|
||||||
|
|
||||||
|
const id = this.allocateId();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const status: SubagentStatus = {
|
||||||
|
id,
|
||||||
|
label: deriveLabel(request, id),
|
||||||
|
agent: request.agent,
|
||||||
|
adHoc: !request.agent,
|
||||||
|
context: this.resolveContext(request.context),
|
||||||
|
state: "queued",
|
||||||
|
cwd: this.cwd,
|
||||||
|
model: request.model,
|
||||||
|
thinking: request.thinking,
|
||||||
|
tools: request.tools ?? "read-only",
|
||||||
|
startedAt: now,
|
||||||
|
elapsedMs: 0,
|
||||||
|
lastEvent: "queued",
|
||||||
|
lastEventAt: now,
|
||||||
|
currentActivity: { type: "queued", summary: "queued", at: now },
|
||||||
|
activityHistory: [{ type: "queued", summary: "queued", at: now }],
|
||||||
|
resultAvailable: false,
|
||||||
|
};
|
||||||
|
const child: RunningChild = { record: { status, activityEvents: [{ type: "queued", summary: "queued", at: now }] }, request: { ...request, prompt, label: status.label, context: status.context, tools: status.tools } };
|
||||||
|
this.children.set(id, child);
|
||||||
|
this.emitMilestone(child, "accepted");
|
||||||
|
this.queue.push(child);
|
||||||
|
this.pumpQueue();
|
||||||
|
return toAccepted(cloneStatus(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
private pumpQueue() {
|
||||||
|
while (this.runningCount() < this.maxConcurrent()) {
|
||||||
|
const child = this.queue.shift();
|
||||||
|
if (!child) break;
|
||||||
|
if (isTerminal(child.record.status.state)) continue;
|
||||||
|
this.start(child);
|
||||||
|
}
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private start(child: RunningChild) {
|
||||||
|
this.setState(child.record.status, "starting", "starting");
|
||||||
|
this.armStartTimer(child);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isTerminal(child.record.status.state)) return;
|
||||||
|
void this.runner
|
||||||
|
.start(child.record.status.id, child.request, this.cwd, this.eventsFor(child.record))
|
||||||
|
.then((handle) => {
|
||||||
|
child.handle = handle;
|
||||||
|
if (isTerminal(child.record.status.state)) void handle.cancel();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.fail(child.record, error instanceof Error ? error.message : String(error));
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private eventsFor(record: ChildRecord): RunnerEvents {
|
||||||
|
return {
|
||||||
|
accepted: (childSession) => {
|
||||||
|
const child = this.findChild(record);
|
||||||
|
if (child) {
|
||||||
|
this.clearTimer(child, "startTimer");
|
||||||
|
this.armRunTimer(child);
|
||||||
|
}
|
||||||
|
if (childSession) record.status.childSession = childSession;
|
||||||
|
this.setState(record.status, "running", "prompt accepted");
|
||||||
|
},
|
||||||
|
running: (event) => {
|
||||||
|
if (!isTerminal(record.status.state)) this.setState(record.status, "running", event);
|
||||||
|
},
|
||||||
|
settling: () => {
|
||||||
|
if (!isTerminal(record.status.state)) this.setState(record.status, "settling", "agent_settled");
|
||||||
|
},
|
||||||
|
completed: (result, stopReason) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const child = this.findChild(record);
|
||||||
|
if (child) this.clearTimers(child);
|
||||||
|
record.result = result;
|
||||||
|
record.status.state = "completed";
|
||||||
|
record.status.completedAt = now;
|
||||||
|
record.status.lastEvent = "completed";
|
||||||
|
record.status.lastEventAt = now;
|
||||||
|
this.recordActivity(record, "completed", now);
|
||||||
|
record.status.stopReason = stopReason;
|
||||||
|
record.status.resultAvailable = true;
|
||||||
|
if (child) {
|
||||||
|
this.armTerminalExpiry(child);
|
||||||
|
this.emitMilestone(child, "completed");
|
||||||
|
}
|
||||||
|
this.pumpQueue();
|
||||||
|
},
|
||||||
|
failed: (error) => this.fail(record, error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private fail(record: ChildRecord, error: string) {
|
||||||
|
if (isTerminal(record.status.state)) return;
|
||||||
|
const child = this.findChild(record);
|
||||||
|
if (child) this.clearTimers(child);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
record.status.state = "failed";
|
||||||
|
record.status.completedAt = now;
|
||||||
|
record.status.lastEvent = "failed";
|
||||||
|
record.status.lastEventAt = now;
|
||||||
|
this.recordActivity(record, "failed", now);
|
||||||
|
record.status.error = error;
|
||||||
|
record.status.stopReason = "failed";
|
||||||
|
if (child) {
|
||||||
|
this.armTerminalExpiry(child);
|
||||||
|
this.emitMilestone(child, "failed");
|
||||||
|
}
|
||||||
|
this.pumpQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private completeWithoutResult(child: RunningChild, state: "cancelled" | "timed_out", reason: string) {
|
||||||
|
if (isTerminal(child.record.status.state)) return;
|
||||||
|
this.clearTimers(child);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
child.record.status.state = state;
|
||||||
|
child.record.status.completedAt = now;
|
||||||
|
child.record.status.lastEvent = state;
|
||||||
|
child.record.status.lastEventAt = now;
|
||||||
|
this.recordActivity(child.record, state, now);
|
||||||
|
child.record.status.stopReason = reason;
|
||||||
|
this.armTerminalExpiry(child);
|
||||||
|
this.emitMilestone(child, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private armStartTimer(child: RunningChild) {
|
||||||
|
const timeout = this.options.timeouts?.startMs ?? DEFAULT_TIMEOUTS.startMs;
|
||||||
|
if (timeout <= 0) return;
|
||||||
|
child.startTimer = setTimeout(() => {
|
||||||
|
this.timeout(child, "start_timeout");
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private armRunTimer(child: RunningChild) {
|
||||||
|
const timeout = this.options.timeouts?.runMs ?? DEFAULT_TIMEOUTS.runMs;
|
||||||
|
if (timeout <= 0) return;
|
||||||
|
child.runTimer = setTimeout(() => {
|
||||||
|
this.timeout(child, "run_timeout");
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private timeout(child: RunningChild, reason: string) {
|
||||||
|
if (isTerminal(child.record.status.state)) return;
|
||||||
|
void child.handle?.cancel();
|
||||||
|
this.completeWithoutResult(child, "timed_out", reason);
|
||||||
|
this.pumpQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private armTerminalExpiry(child: RunningChild) {
|
||||||
|
const ttl = this.options.recentTerminalTtlMs;
|
||||||
|
if (ttl === undefined || ttl <= 0) return;
|
||||||
|
this.clearTimer(child, "expiryTimer");
|
||||||
|
child.expiryTimer = setTimeout(() => {
|
||||||
|
child.expiryTimer = undefined;
|
||||||
|
const id = child.record.status.id;
|
||||||
|
if (this.children.get(id) !== child || !isTerminal(child.record.status.state)) return;
|
||||||
|
this.children.delete(id);
|
||||||
|
this.emitChange();
|
||||||
|
}, ttl);
|
||||||
|
child.expiryTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimers(child: RunningChild) {
|
||||||
|
this.clearTimer(child, "startTimer");
|
||||||
|
this.clearTimer(child, "runTimer");
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer(child: RunningChild, key: "startTimer" | "runTimer" | "expiryTimer") {
|
||||||
|
const timer = child[key];
|
||||||
|
if (!timer) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
child[key] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findChild(record: ChildRecord): RunningChild | undefined {
|
||||||
|
return [...this.children.values()].find((child) => child.record === record);
|
||||||
|
}
|
||||||
|
|
||||||
|
activity(id: string) {
|
||||||
|
return this.require(id).record.activityEvents.map((event) => ({ ...event }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(status: SubagentStatus, state: SubagentStatus["state"], event: RunnerActivity) {
|
||||||
|
if (isTerminal(status.state)) return;
|
||||||
|
const record = this.require(status.id).record;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const activity = this.recordActivity(record, event, now);
|
||||||
|
status.state = state;
|
||||||
|
status.lastEvent = activity.type;
|
||||||
|
status.lastEventAt = now;
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordActivity(record: ChildRecord, event: RunnerActivity, at: string) {
|
||||||
|
const activity = normalizeActivity(event, at);
|
||||||
|
record.activityEvents.push(activity);
|
||||||
|
const summary = summarizeActivity(activity);
|
||||||
|
record.status.currentActivity = summary;
|
||||||
|
record.status.activityHistory.push(summary);
|
||||||
|
if (record.status.activityHistory.length > MAX_ACTIVITY_HISTORY) {
|
||||||
|
record.status.activityHistory.splice(0, record.status.activityHistory.length - MAX_ACTIVITY_HISTORY);
|
||||||
|
}
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
private require(id: string): RunningChild {
|
||||||
|
const child = this.children.get(id);
|
||||||
|
if (!child) throw new Error(`unknown subagent id: ${id}`);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveContext(context: ContextMode | undefined): ContextMode {
|
||||||
|
if (context === undefined) return "independent";
|
||||||
|
if (context !== "independent" && context !== "fork") throw new Error(`unknown context: ${context}`);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private maxConcurrent(): number {
|
||||||
|
return Math.max(1, this.options.maxConcurrent ?? 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private runningCount(): number {
|
||||||
|
return [...this.children.values()].filter((child) => ["starting", "running", "settling"].includes(child.record.status.state)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitMilestone(child: RunningChild, event: string) {
|
||||||
|
this.options.onMilestone?.(cloneStatus(child.record.status), event);
|
||||||
|
this.emitChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitChange() {
|
||||||
|
this.options.onChange?.(this.list());
|
||||||
|
for (const waiter of this.waiters) waiter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private waitReady(ids: string[], mode: SubagentWaitMode): boolean {
|
||||||
|
const terminal = (id: string) => isTerminal(this.require(id).record.status.state);
|
||||||
|
return mode === "all" ? ids.every(terminal) : ids.some(terminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextChange(timeoutMs: number | undefined, signal: AbortSignal | undefined): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const cleanup = () => {
|
||||||
|
this.waiters.delete(resolveOnce);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
signal?.removeEventListener("abort", abort);
|
||||||
|
};
|
||||||
|
const resolveOnce = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const abort = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("subagent wait aborted"));
|
||||||
|
};
|
||||||
|
this.waiters.add(resolveOnce);
|
||||||
|
signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
if (timeoutMs !== undefined) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("subagent wait timed out"));
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private allocateId(): string {
|
||||||
|
this.nextChild += 1;
|
||||||
|
return `sg-${Date.now().toString(36)}-${this.nextChild.toString(36)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveLabel(request: SpawnRequest, id: string): string {
|
||||||
|
const explicit = normalizeLabel(request.label);
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const agent = normalizeLabel(request.agent);
|
||||||
|
if (agent) return agent;
|
||||||
|
return promptLabel(request.prompt) ?? `ad-hoc ${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptLabel(prompt: string): string | undefined {
|
||||||
|
const normalized = normalizeLabel(prompt);
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
return truncateLabel(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLabel(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
const normalized = value.replace(/\s+/gu, " ").trim();
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateLabel(label: string): string {
|
||||||
|
const maxLength = 80;
|
||||||
|
if (label.length <= maxLength) return label;
|
||||||
|
return `${label.slice(0, maxLength - 1).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminal(state: SubagentStatus["state"]): boolean {
|
||||||
|
return isTerminalState(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeActivity(event: RunnerActivity, at: string) {
|
||||||
|
if (typeof event === "string") return { type: event, summary: event, at };
|
||||||
|
const type = typeof event.type === "string" ? event.type : "activity";
|
||||||
|
const role = typeof event.role === "string" ? event.role : undefined;
|
||||||
|
const tool = toolFromActivity(event);
|
||||||
|
const phase = typeof event.phase === "string" ? event.phase : phaseFromType(type, event);
|
||||||
|
const text = textFromActivity(event);
|
||||||
|
const input = inputFromActivity(event);
|
||||||
|
const output = "output" in event ? event.output : "result" in event ? event.result : "partialResult" in event ? event.partialResult : undefined;
|
||||||
|
const error = typeof event.error === "string" ? event.error : undefined;
|
||||||
|
return { type, summary: summaryFor({ type, role, tool, phase, input, output, error }), at, role, tool, phase, text, input, output, error, payload: { ...event } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeActivity(activity: ReturnType<typeof normalizeActivity>) {
|
||||||
|
const { type, summary, at, role, tool, phase } = activity;
|
||||||
|
return { type, summary, at, role, tool, phase };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolFromActivity(event: Record<string, unknown>): string | undefined {
|
||||||
|
for (const key of ["tool", "toolName", "name"]) {
|
||||||
|
const value = event[key];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseFromType(type: string, event: Record<string, unknown>): string | undefined {
|
||||||
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
|
if (assistantEvent && typeof assistantEvent === "object" && !Array.isArray(assistantEvent)) {
|
||||||
|
const assistantType = (assistantEvent as { type?: unknown }).type;
|
||||||
|
if (typeof assistantType === "string") return assistantType;
|
||||||
|
}
|
||||||
|
if (type.endsWith("_start")) return "started";
|
||||||
|
if (type.endsWith("_started")) return "started";
|
||||||
|
if (type.endsWith("_update")) return "update";
|
||||||
|
if (type.endsWith("_delta")) return "delta";
|
||||||
|
if (type.endsWith("_end")) return "completed";
|
||||||
|
if (type.endsWith("_completed")) return "completed";
|
||||||
|
if (type.endsWith("_failed")) return "failed";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textFromActivity(event: Record<string, unknown>): string | undefined {
|
||||||
|
for (const key of ["text", "body", "content", "delta"]) {
|
||||||
|
const value = event[key];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
}
|
||||||
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
|
if (assistantEvent && typeof assistantEvent === "object" && !Array.isArray(assistantEvent)) {
|
||||||
|
for (const key of ["delta", "content"]) {
|
||||||
|
const value = (assistantEvent as Record<string, unknown>)[key];
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputFromActivity(event: Record<string, unknown>): unknown {
|
||||||
|
if ("input" in event) return event.input;
|
||||||
|
if ("args" in event) return event.args;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryFor(activity: { type: string; role?: string; tool?: string; phase?: string; input?: unknown; output?: unknown; error?: string }): string {
|
||||||
|
if (activity.error) return `${activity.tool ?? activity.type} failed: ${activity.error}`;
|
||||||
|
if (activity.tool) return `${activity.tool}${inputHint(activity.input)}`;
|
||||||
|
if (activity.type.startsWith("message")) return `${activity.role ?? "assistant"} message${activity.phase ? ` ${activity.phase}` : ""}`;
|
||||||
|
return activity.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputHint(input: unknown): string {
|
||||||
|
if (!input || typeof input !== "object" || Array.isArray(input)) return "";
|
||||||
|
const path = (input as { path?: unknown }).path;
|
||||||
|
if (typeof path === "string" && path.trim()) return ` ${path.trim()}`;
|
||||||
|
const command = (input as { command?: unknown }).command;
|
||||||
|
if (typeof command === "string" && command.trim()) return ` ${truncateActivityHint(command.trim())}`;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateActivityHint(value: string): string {
|
||||||
|
return value.length <= 80 ? value : `${value.slice(0, 79).trimEnd()}…`;
|
||||||
|
}
|
||||||
123
modules/agents/pi/extensions/subagents/types.ts
Normal file
123
modules/agents/pi/extensions/subagents/types.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
export type ContextMode = "independent" | "fork";
|
||||||
|
|
||||||
|
export const SUBAGENT_STATES = ["queued", "starting", "running", "settling", "completed", "failed", "cancelled", "timed_out", "orphaned"] as const;
|
||||||
|
export const SUBAGENT_TERMINAL_STATES = ["completed", "failed", "cancelled", "timed_out", "orphaned"] as const;
|
||||||
|
|
||||||
|
export type SubagentState = (typeof SUBAGENT_STATES)[number];
|
||||||
|
|
||||||
|
export interface ToolProfile {
|
||||||
|
activeTools: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnRequest {
|
||||||
|
prompt: string;
|
||||||
|
label?: string;
|
||||||
|
context?: ContextMode;
|
||||||
|
agent?: string;
|
||||||
|
model?: string;
|
||||||
|
thinking?: string;
|
||||||
|
tools?: string;
|
||||||
|
toolProfile?: ToolProfile;
|
||||||
|
agentBody?: string;
|
||||||
|
parentSessionFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnAccepted {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
context: ContextMode;
|
||||||
|
tools: string;
|
||||||
|
state: SubagentState;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentActivitySummary {
|
||||||
|
type: string;
|
||||||
|
summary: string;
|
||||||
|
at: string;
|
||||||
|
role?: string;
|
||||||
|
tool?: string;
|
||||||
|
phase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentActivityEvent extends SubagentActivitySummary {
|
||||||
|
text?: string;
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
error?: string;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentCurrentActivity extends SubagentActivitySummary {}
|
||||||
|
|
||||||
|
export type RunnerActivity = string | Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface SubagentStatus {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
agent?: string;
|
||||||
|
adHoc: boolean;
|
||||||
|
context: ContextMode;
|
||||||
|
state: SubagentState;
|
||||||
|
cwd: string;
|
||||||
|
model?: string;
|
||||||
|
thinking?: string;
|
||||||
|
tools: string;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
elapsedMs: number;
|
||||||
|
lastEvent?: string;
|
||||||
|
lastEventAt?: string;
|
||||||
|
currentActivity?: SubagentCurrentActivity;
|
||||||
|
activityHistory: SubagentActivitySummary[];
|
||||||
|
stopReason?: string;
|
||||||
|
resultAvailable: boolean;
|
||||||
|
childSession?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentResult {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
state: SubagentState;
|
||||||
|
running: boolean;
|
||||||
|
resultAvailable: boolean;
|
||||||
|
result?: string;
|
||||||
|
error?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubagentWaitMode = "all" | "any";
|
||||||
|
|
||||||
|
export interface SubagentWaitResult {
|
||||||
|
ids: string[];
|
||||||
|
mode: SubagentWaitMode;
|
||||||
|
ready: boolean;
|
||||||
|
results: SubagentResult[];
|
||||||
|
pending: SubagentStatus[];
|
||||||
|
timedOut: boolean;
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildRecord {
|
||||||
|
status: SubagentStatus;
|
||||||
|
activityEvents: SubagentActivityEvent[];
|
||||||
|
result?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunnerEvents {
|
||||||
|
accepted(childSession?: string): void;
|
||||||
|
running(event: RunnerActivity): void;
|
||||||
|
settling(): void;
|
||||||
|
completed(result: string, stopReason?: string): void;
|
||||||
|
failed(error: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildHandle {
|
||||||
|
cancel(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildRunner {
|
||||||
|
start(id: string, request: SpawnRequest, cwd: string, events: RunnerEvents): Promise<ChildHandle>;
|
||||||
|
}
|
||||||
89
modules/agents/pi/extensions/subagents/ui.test.ts
Normal file
89
modules/agents/pi/extensions/subagents/ui.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import type { SubagentState, SubagentStatus } from "./types.ts";
|
||||||
|
import { renderInspector, renderSummary, widget } from "./ui.ts";
|
||||||
|
|
||||||
|
function status(overrides: Partial<SubagentStatus> & { id: string; label: string; state: SubagentState }): SubagentStatus {
|
||||||
|
return {
|
||||||
|
adHoc: true,
|
||||||
|
context: "independent",
|
||||||
|
cwd: "/tmp",
|
||||||
|
elapsedMs: 0,
|
||||||
|
activityHistory: [],
|
||||||
|
resultAvailable: false,
|
||||||
|
startedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
tools: "inherit",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("compact monitor aggregates visible children by actionable lifecycle group", () => {
|
||||||
|
assert.deepEqual(renderSummary([]), []);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
renderSummary([
|
||||||
|
status({ id: "queued", label: "Queued", state: "queued" }),
|
||||||
|
status({ id: "starting", label: "Starting", state: "starting" }),
|
||||||
|
status({ id: "running", label: "Running", state: "running" }),
|
||||||
|
status({ id: "settling", label: "Settling", state: "settling" }),
|
||||||
|
status({ id: "completed", label: "Completed", state: "completed", resultAvailable: true }),
|
||||||
|
status({ id: "failed", label: "Failed", state: "failed", error: "boom" }),
|
||||||
|
status({ id: "timed-out", label: "Timed out", state: "timed_out" }),
|
||||||
|
status({ id: "cancelled", label: "Cancelled", state: "cancelled" }),
|
||||||
|
]),
|
||||||
|
["subagents: queued 1 · running 2 · settling 1 · completed 1 · failed 1 · timed out 1 · cancelled 1"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expanded monitor shows concise current activity summaries instead of raw event types", () => {
|
||||||
|
const rendered = widget([
|
||||||
|
status({
|
||||||
|
id: "sg-reading",
|
||||||
|
label: "Audit guest enablement plan",
|
||||||
|
state: "running",
|
||||||
|
elapsedMs: 12_000,
|
||||||
|
lastEvent: "message_update",
|
||||||
|
currentActivity: {
|
||||||
|
type: "message_update",
|
||||||
|
summary: "read secret-notes.md",
|
||||||
|
at: "2026-08-01T00:00:12.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
], true)().render(240);
|
||||||
|
|
||||||
|
assert.deepEqual(rendered, ["▶ running 12s Audit guest enablement plan last: read secret-notes.md"]);
|
||||||
|
assert.doesNotMatch(rendered.join("\n"), /message_update|private transcript body/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expanded monitor renders one truncated row per child with state, elapsed time, and activity marker", () => {
|
||||||
|
const lines = renderInspector([
|
||||||
|
status({
|
||||||
|
id: "sg-running",
|
||||||
|
label: "Audit unusually verbose guest enablement migration plan",
|
||||||
|
state: "running",
|
||||||
|
elapsedMs: 65_000,
|
||||||
|
lastEvent: "message_update",
|
||||||
|
}),
|
||||||
|
status({
|
||||||
|
id: "sg-completed",
|
||||||
|
label: "Summarize review",
|
||||||
|
state: "completed",
|
||||||
|
elapsedMs: 3_600_000,
|
||||||
|
lastEvent: "completed",
|
||||||
|
resultAvailable: true,
|
||||||
|
}),
|
||||||
|
status({ id: "sg-failed", label: "Run risky test", state: "failed", elapsedMs: 2_000, error: "exit 1" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(lines.length, 3);
|
||||||
|
assert.match(lines[0], /^▶ running +1m05s +Audit unusually verbose guest enablement migration plan +last: message_update$/u);
|
||||||
|
assert.equal(lines[1], "✓ completed 1h00m00s Summarize review result: available");
|
||||||
|
assert.equal(lines[2], "✗ failed 2s Run risky test error: exit 1");
|
||||||
|
|
||||||
|
const rendered = widget([
|
||||||
|
status({ id: "sg-running", label: "Audit unusually verbose guest enablement migration plan", state: "running", elapsedMs: 65_000, lastEvent: "message_update" }),
|
||||||
|
], true)().render(32);
|
||||||
|
|
||||||
|
assert.deepEqual(rendered, ["▶ running 1m05s Audit unusual…"]);
|
||||||
|
assert.ok(rendered.every((line) => line.length <= 32));
|
||||||
|
});
|
||||||
81
modules/agents/pi/extensions/subagents/ui.ts
Normal file
81
modules/agents/pi/extensions/subagents/ui.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { SubagentState, SubagentStatus } from "./types.ts";
|
||||||
|
|
||||||
|
const COMPACT_GROUPS: Array<{ label: string; states: SubagentState[] }> = [
|
||||||
|
{ label: "queued", states: ["queued"] },
|
||||||
|
{ label: "running", states: ["starting", "running"] },
|
||||||
|
{ label: "settling", states: ["settling"] },
|
||||||
|
{ label: "completed", states: ["completed"] },
|
||||||
|
{ label: "failed", states: ["failed"] },
|
||||||
|
{ label: "timed out", states: ["timed_out"] },
|
||||||
|
{ label: "cancelled", states: ["cancelled"] },
|
||||||
|
{ label: "orphaned", states: ["orphaned"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATE_PRESENTATION: Record<SubagentState, { icon: string; label: string }> = {
|
||||||
|
queued: { icon: "…", label: "queued" },
|
||||||
|
starting: { icon: "◌", label: "starting" },
|
||||||
|
running: { icon: "▶", label: "running" },
|
||||||
|
settling: { icon: "◒", label: "settling" },
|
||||||
|
completed: { icon: "✓", label: "completed" },
|
||||||
|
failed: { icon: "✗", label: "failed" },
|
||||||
|
cancelled: { icon: "■", label: "cancelled" },
|
||||||
|
timed_out: { icon: "⏱", label: "timed out" },
|
||||||
|
orphaned: { icon: "?", label: "orphaned" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function renderSummary(statuses: SubagentStatus[]): string[] {
|
||||||
|
const groups = COMPACT_GROUPS.map((group) => ({
|
||||||
|
label: group.label,
|
||||||
|
count: statuses.filter((status) => group.states.includes(status.state)).length,
|
||||||
|
})).filter((group) => group.count > 0);
|
||||||
|
|
||||||
|
if (groups.length === 0) return [];
|
||||||
|
return [`subagents: ${groups.map((group) => `${group.label} ${group.count}`).join(" · ")}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderInspector(statuses: SubagentStatus[]): string[] {
|
||||||
|
return statuses.map((status) => renderStatusRow(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function widget(statuses: SubagentStatus[], expanded: boolean) {
|
||||||
|
return () => ({
|
||||||
|
invalidate() {},
|
||||||
|
render(width: number) {
|
||||||
|
return (expanded ? renderInspector(statuses) : renderSummary(statuses)).map((line) => truncateLine(line, width));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatusRow(status: SubagentStatus): string {
|
||||||
|
const presentation = STATE_PRESENTATION[status.state];
|
||||||
|
const marker = statusMarker(status);
|
||||||
|
return `${presentation.icon} ${presentation.label.padEnd(9)} ${formatDuration(status.elapsedMs)} ${status.label}${marker ? ` ${marker}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusMarker(status: SubagentStatus): string | undefined {
|
||||||
|
if (status.error) return `error: ${status.error}`;
|
||||||
|
if (status.resultAvailable) return "result: available";
|
||||||
|
if (status.currentActivity) return `last: ${status.currentActivity.summary}`;
|
||||||
|
if (status.lastEvent) return `last: ${status.lastEvent}`;
|
||||||
|
if (status.state === "queued") return "waiting";
|
||||||
|
if (status.state === "settling") return "settling";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(elapsedMs: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000));
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) return `${hours}h${String(minutes).padStart(2, "0")}m${String(seconds).padStart(2, "0")}s`;
|
||||||
|
if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
||||||
|
return `${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateLine(line: string, width: number): string {
|
||||||
|
if (width <= 0) return "";
|
||||||
|
if (line.length <= width) return line;
|
||||||
|
if (width === 1) return "…";
|
||||||
|
return `${line.slice(0, width - 1)}…`;
|
||||||
|
}
|
||||||
171
modules/agents/pi/patches/pi-flex-spacer.patch
Normal file
171
modules/agents/pi/patches/pi-flex-spacer.patch
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts
|
||||||
|
--- a/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400
|
||||||
|
+++ b/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400
|
||||||
|
@@ -310,7 +310,7 @@ export class TUI extends Container {
|
||||||
|
private cursorRow = 0; // Logical cursor row (end of rendered content)
|
||||||
|
private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
||||||
|
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
||||||
|
- private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
|
||||||
|
+ private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK !== "0"; // Clear empty rows when content shrinks (default: on)
|
||||||
|
private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
||||||
|
private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
||||||
|
private fullRedrawCount = 0;
|
||||||
|
|
||||||
|
diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts
|
||||||
|
--- a/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400
|
||||||
|
+++ b/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400
|
||||||
|
@@ -1093,11 +1093,11 @@ export class SettingsManager {
|
||||||
|
}
|
||||||
|
|
||||||
|
getClearOnShrink(): boolean {
|
||||||
|
- // Settings takes precedence, then env var, then default false
|
||||||
|
+ // Settings takes precedence, then env var, then default true
|
||||||
|
if (this.settings.terminal?.clearOnShrink !== undefined) {
|
||||||
|
return this.settings.terminal.clearOnShrink;
|
||||||
|
}
|
||||||
|
- return process.env.PI_CLEAR_ON_SHRINK === "1";
|
||||||
|
+ return process.env.PI_CLEAR_ON_SHRINK !== "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
setClearOnShrink(enabled: boolean): void {
|
||||||
|
|
||||||
|
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
|
||||||
|
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:41:36.963495957 -0400
|
||||||
|
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:43:04.876341236 -0400
|
||||||
|
@@ -210,6 +210,47 @@
|
||||||
|
return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
+class FlexSpacerBottomLayout implements Component {
|
||||||
|
+ private readonly ui: TUI;
|
||||||
|
+ private readonly flowChildren: Component[];
|
||||||
|
+ private readonly pinnedChildren: Component[];
|
||||||
|
+
|
||||||
|
+ constructor(ui: TUI, flowChildren: Component[], pinnedChildren: Component[]) {
|
||||||
|
+ this.ui = ui;
|
||||||
|
+ this.flowChildren = flowChildren;
|
||||||
|
+ this.pinnedChildren = pinnedChildren;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ invalidate(): void {
|
||||||
|
+ for (const child of [...this.flowChildren, ...this.pinnedChildren]) {
|
||||||
|
+ child.invalidate();
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ private renderGroup(children: Component[], width: number): string[] {
|
||||||
|
+ const lines: string[] = [];
|
||||||
|
+ for (const child of children) {
|
||||||
|
+ for (const line of child.render(width)) {
|
||||||
|
+ lines.push(line);
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+ return lines;
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ render(width: number): string[] {
|
||||||
|
+ const flowLines = this.renderGroup(this.flowChildren, width);
|
||||||
|
+ const pinnedLines = this.renderGroup(this.pinnedChildren, width);
|
||||||
|
+ const terminalRows = this.ui.terminal.rows;
|
||||||
|
+ const spacerRows = Math.max(0, terminalRows - flowLines.length - pinnedLines.length);
|
||||||
|
+
|
||||||
|
+ return [
|
||||||
|
+ ...flowLines,
|
||||||
|
+ ...Array.from({ length: spacerRows }, () => ""),
|
||||||
|
+ ...pinnedLines,
|
||||||
|
+ ];
|
||||||
|
+ }
|
||||||
|
+}
|
||||||
|
+
|
||||||
|
const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING =
|
||||||
|
"Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings.";
|
||||||
|
|
||||||
|
@@ -335,6 +376,7 @@
|
||||||
|
private fdPath: string | undefined;
|
||||||
|
private editorContainer: Container;
|
||||||
|
private footer: FooterComponent;
|
||||||
|
+ private footerContainer: Container;
|
||||||
|
private footerDataProvider: FooterDataProvider;
|
||||||
|
// Stored so the same manager can be injected into custom editors, selectors, and extension UI.
|
||||||
|
private keybindings: KeybindingsManager;
|
||||||
|
@@ -477,7 +519,9 @@
|
||||||
|
this.editorContainer = new Container();
|
||||||
|
this.editorContainer.addChild(this.editor as Component);
|
||||||
|
this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
|
||||||
|
+ this.footerContainer = new Container();
|
||||||
|
this.footer = new FooterComponent(this.session, this.footerDataProvider);
|
||||||
|
+ this.footerContainer.addChild(this.footer);
|
||||||
|
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
|
||||||
|
|
||||||
|
// Load hide thinking block setting
|
||||||
|
@@ -704,19 +748,25 @@
|
||||||
|
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
- // Add header container as first child. Populate it after applying theme settings.
|
||||||
|
- // Keep loaded resources before chat so restored session messages never precede them.
|
||||||
|
- this.ui.addChild(this.headerContainer);
|
||||||
|
- this.ui.addChild(this.loadedResourcesContainer);
|
||||||
|
-
|
||||||
|
- this.ui.addChild(this.chatContainer);
|
||||||
|
- this.ui.addChild(this.pendingMessagesContainer);
|
||||||
|
- this.ui.addChild(this.statusContainer);
|
||||||
|
this.renderWidgets(); // Initialize with default spacer
|
||||||
|
- this.ui.addChild(this.widgetContainerAbove);
|
||||||
|
- this.ui.addChild(this.editorContainer);
|
||||||
|
- this.ui.addChild(this.widgetContainerBelow);
|
||||||
|
- this.ui.addChild(this.footer);
|
||||||
|
+ this.ui.addChild(
|
||||||
|
+ new FlexSpacerBottomLayout(
|
||||||
|
+ this.ui,
|
||||||
|
+ [
|
||||||
|
+ this.headerContainer,
|
||||||
|
+ this.loadedResourcesContainer,
|
||||||
|
+ this.chatContainer,
|
||||||
|
+ ],
|
||||||
|
+ [
|
||||||
|
+ this.pendingMessagesContainer,
|
||||||
|
+ this.statusContainer,
|
||||||
|
+ this.widgetContainerAbove,
|
||||||
|
+ this.editorContainer,
|
||||||
|
+ this.widgetContainerBelow,
|
||||||
|
+ this.footerContainer,
|
||||||
|
+ ],
|
||||||
|
+ ),
|
||||||
|
+ );
|
||||||
|
this.ui.setFocus(this.editor);
|
||||||
|
|
||||||
|
this.setupKeyHandlers();
|
||||||
|
@@ -2033,25 +2083,25 @@
|
||||||
|
| ((tui: TUI, thm: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void })
|
||||||
|
| undefined,
|
||||||
|
): void {
|
||||||
|
- // Dispose existing custom footer
|
||||||
|
+ // Dispose existing custom footer
|
||||||
|
if (this.customFooter?.dispose) {
|
||||||
|
this.customFooter.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
- // Remove current footer from UI
|
||||||
|
+ // Remove current footer from its pinned layout slot.
|
||||||
|
if (this.customFooter) {
|
||||||
|
- this.ui.removeChild(this.customFooter);
|
||||||
|
+ this.footerContainer.removeChild(this.customFooter);
|
||||||
|
} else {
|
||||||
|
- this.ui.removeChild(this.footer);
|
||||||
|
+ this.footerContainer.removeChild(this.footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (factory) {
|
||||||
|
// Create and add custom footer, passing the data provider
|
||||||
|
this.customFooter = factory(this.ui, theme, this.footerDataProvider);
|
||||||
|
- this.ui.addChild(this.customFooter);
|
||||||
|
+ this.footerContainer.addChild(this.customFooter);
|
||||||
|
} else {
|
||||||
|
// Restore built-in footer
|
||||||
|
this.customFooter = undefined;
|
||||||
|
- this.ui.addChild(this.footer);
|
||||||
|
+ this.footerContainer.addChild(this.footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ui.requestRender();
|
||||||
22
modules/agents/pi/patches/pi-tool-lookup-validation.patch
Normal file
22
modules/agents/pi/patches/pi-tool-lookup-validation.patch
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts
|
||||||
|
--- a/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:36.970496010 -0400
|
||||||
|
+++ b/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:37.028186009 -0400
|
||||||
|
@@ -74,8 +74,7 @@
|
||||||
|
function commandExists(cmd: string): boolean {
|
||||||
|
try {
|
||||||
|
const result = spawnSync(cmd, ["--version"], { stdio: "pipe" });
|
||||||
|
- // Check for ENOENT error (command not found)
|
||||||
|
- return result.error === undefined || result.error === null;
|
||||||
|
+ return (result.error === undefined || result.error === null) && result.status === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
@@ -88,7 +87,7 @@
|
||||||
|
|
||||||
|
// Check our tools directory first
|
||||||
|
const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : ""));
|
||||||
|
- if (existsSync(localPath)) {
|
||||||
|
+ if (existsSync(localPath) && commandExists(localPath)) {
|
||||||
|
return localPath;
|
||||||
|
}
|
||||||
|
|
||||||
222
modules/agents/pi/pi.nix
Normal file
222
modules/agents/pi/pi.nix
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# Pi, a terminal coding agent, for the primary user, configured through
|
||||||
|
# home-manager, which ships the package and manages ~/.pi/agent.
|
||||||
|
# The login credential is left unmanaged, so it survives rebuilds.
|
||||||
|
let
|
||||||
|
cfg = config.modules.agents.pi;
|
||||||
|
user = config.user.name;
|
||||||
|
piDir = "${config.users.users.${user}.home}/.pi/agent";
|
||||||
|
reservedToolProfiles = [
|
||||||
|
"none"
|
||||||
|
"read-only"
|
||||||
|
"read-only-with-safe-bash"
|
||||||
|
"full-tools"
|
||||||
|
];
|
||||||
|
subagentsConfig =
|
||||||
|
lib.optionalAttrs (cfg.subagents.defaultContext != null) {
|
||||||
|
defaultContext = cfg.subagents.defaultContext;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.subagents.defaultTools != null) {
|
||||||
|
defaultTools = cfg.subagents.defaultTools;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.subagents.maxConcurrent != null) {
|
||||||
|
maxConcurrent = cfg.subagents.maxConcurrent;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.subagents.recentTerminalTtlMs != null) {
|
||||||
|
recentTerminalTtlMs = cfg.subagents.recentTerminalTtlMs;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (
|
||||||
|
cfg.subagents.ui.enabled != null || cfg.subagents.ui.defaultExpanded != null
|
||||||
|
) {
|
||||||
|
ui =
|
||||||
|
lib.optionalAttrs (cfg.subagents.ui.enabled != null) {
|
||||||
|
enabled = cfg.subagents.ui.enabled;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.subagents.ui.defaultExpanded != null) {
|
||||||
|
defaultExpanded = cfg.subagents.ui.defaultExpanded;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (cfg.subagents.toolProfiles != { }) {
|
||||||
|
toolProfiles = cfg.subagents.toolProfiles;
|
||||||
|
};
|
||||||
|
subagentsJson = (pkgs.formats.json { }).generate "pi-subagents.json" subagentsConfig;
|
||||||
|
patchedPi = pkgs.pi-coding-agent.overrideAttrs (old: {
|
||||||
|
patches = (old.patches or [ ]) ++ [
|
||||||
|
./patches/pi-flex-spacer.patch
|
||||||
|
./patches/pi-tool-lookup-validation.patch
|
||||||
|
];
|
||||||
|
});
|
||||||
|
herdrPiIntegration = pkgs.stdenvNoCC.mkDerivation {
|
||||||
|
name = "herdr-pi-integration";
|
||||||
|
nativeBuildInputs = [ pkgs.herdr ];
|
||||||
|
phases = [ "installPhase" ];
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $TMPDIR/home/.pi/agent/extensions
|
||||||
|
HOME=$TMPDIR/home herdr integration install pi
|
||||||
|
mkdir -p $out
|
||||||
|
cp $TMPDIR/home/.pi/agent/extensions/herdr-agent-state.ts $out/herdr-agent-state.ts
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
piExtensions = pkgs.stdenvNoCC.mkDerivation {
|
||||||
|
name = "pi-extensions";
|
||||||
|
phases = [ "installPhase" ];
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $out
|
||||||
|
cp -R ${./extensions}/. $out/
|
||||||
|
cp ${herdrPiIntegration}/herdr-agent-state.ts $out/herdr-agent-state.ts
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.modules.agents.pi = {
|
||||||
|
enable = lib.mkEnableOption ''
|
||||||
|
Pi, a terminal coding agent, configured via home-manager'';
|
||||||
|
|
||||||
|
subagents = {
|
||||||
|
defaultContext = lib.mkOption {
|
||||||
|
type = lib.types.nullOr (lib.types.enum [
|
||||||
|
"independent"
|
||||||
|
"fork"
|
||||||
|
]);
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Default context mode for subagents.
|
||||||
|
Left null, the extension keeps its in-code default.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
defaultTools = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
example = "read-only-with-safe-bash";
|
||||||
|
description = ''
|
||||||
|
Default tool profile for subagents.
|
||||||
|
Left null, the extension keeps its in-code default.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
maxConcurrent = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.ints.positive;
|
||||||
|
default = null;
|
||||||
|
example = 4;
|
||||||
|
description = ''
|
||||||
|
Maximum number of child processes allowed to run concurrently.
|
||||||
|
Left null, the extension keeps its in-code default.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
recentTerminalTtlMs = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.ints.unsigned;
|
||||||
|
default = null;
|
||||||
|
example = 600000;
|
||||||
|
description = ''
|
||||||
|
Milliseconds to retain terminal subagents in the recent work set.
|
||||||
|
Zero disables time-based retention.
|
||||||
|
Left null, the extension keeps its in-code default.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
ui = {
|
||||||
|
enabled = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.bool;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Whether the extension renders its built-in subagent monitor.
|
||||||
|
Left null, the extension keeps its in-code default.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
defaultExpanded = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.bool;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Whether the built-in subagent monitor starts expanded.
|
||||||
|
Left null, the extension keeps its in-code default.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
toolProfiles = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf (
|
||||||
|
lib.types.submodule {
|
||||||
|
options.activeTools = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
description = "Pi tools made available to a child using this profile.";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
default = { };
|
||||||
|
example = {
|
||||||
|
review = {
|
||||||
|
activeTools = [
|
||||||
|
"read"
|
||||||
|
"grep"
|
||||||
|
"find"
|
||||||
|
"ls"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
description = ''
|
||||||
|
Custom named tool profiles for subagents.
|
||||||
|
The extension's reserved built-in profile names cannot be redefined.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
assertions = [
|
||||||
|
{
|
||||||
|
assertion = lib.intersectLists reservedToolProfiles (
|
||||||
|
builtins.attrNames cfg.subagents.toolProfiles
|
||||||
|
) == [ ];
|
||||||
|
message = "modules.agents.pi.subagents.toolProfiles may not redefine the reserved profiles: ${lib.concatStringsSep ", " reservedToolProfiles}.";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
home-manager.users.${user} = {
|
||||||
|
programs.pi-coding-agent = {
|
||||||
|
enable = true;
|
||||||
|
package = patchedPi;
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
defaultProvider = "openai-codex";
|
||||||
|
defaultModel = "gpt-5.5";
|
||||||
|
defaultThinkingLevel = "medium";
|
||||||
|
theme = "dark";
|
||||||
|
enableInstallTelemetry = false;
|
||||||
|
enableAnalytics = false;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
home.file =
|
||||||
|
{
|
||||||
|
# The first declarative rollout replaces the interactive settings file.
|
||||||
|
# Login state stays in auth.json, which this module does not manage.
|
||||||
|
"${piDir}/settings.json".force = true;
|
||||||
|
|
||||||
|
"${piDir}/extensions" = {
|
||||||
|
source = piExtensions;
|
||||||
|
recursive = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
"${piDir}/prompts" = {
|
||||||
|
source = ./prompts;
|
||||||
|
recursive = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (subagentsConfig != { }) {
|
||||||
|
# Declaring any global override makes Nix the owner of the runtime file.
|
||||||
|
"${piDir}/subagents.json" = {
|
||||||
|
source = subagentsJson;
|
||||||
|
force = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
0
modules/agents/pi/prompts/.gitkeep
Normal file
0
modules/agents/pi/prompts/.gitkeep
Normal file
@@ -10,10 +10,21 @@ let
|
|||||||
user = config.user.name;
|
user = config.user.name;
|
||||||
|
|
||||||
# The skills installed globally, as derivations from the skills flake.
|
# The skills installed globally, as derivations from the skills flake.
|
||||||
# wiki reads the personal Obsidian vault, and consume mines a source into it.
|
# grill interviews the operator relentlessly to resolve a plan before building.
|
||||||
|
# design-skill drafts and audits Agent Skills for structural predictability.
|
||||||
|
# wayfinder, research, prototype, slice, and subagents guide work from exploration through implementation tickets and delegation.
|
||||||
|
# implement, test-driven-development, and review guide execution and validation once tickets are ready.
|
||||||
skills = with inputs.skills.packages.${pkgs.stdenv.hostPlatform.system}; [
|
skills = with inputs.skills.packages.${pkgs.stdenv.hostPlatform.system}; [
|
||||||
wiki
|
grill
|
||||||
consume
|
design-skill
|
||||||
|
wayfinder
|
||||||
|
research
|
||||||
|
prototype
|
||||||
|
slice
|
||||||
|
subagents
|
||||||
|
implement
|
||||||
|
test-driven-development
|
||||||
|
review
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{ config, lib, ... }:
|
{
|
||||||
|
config,
|
||||||
|
inputs,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
# Firefox as the desktop browser: stock mainline, hardened and de-monetized by policy.
|
# Firefox as the desktop browser: stock mainline, hardened and de-monetized by policy.
|
||||||
let
|
let
|
||||||
cfg = config.modules.desktop.firefox;
|
cfg = config.modules.desktop.firefox;
|
||||||
user = config.user.name;
|
user = config.user.name;
|
||||||
|
firefoxAddons = inputs.firefox-addons.packages.${pkgs.stdenv.hostPlatform.system};
|
||||||
# A force-installed extension, keyed at the call site by the add-on's own id.
|
|
||||||
# Firefox fetches the signed add-on from Mozilla's site and enables it automatically.
|
|
||||||
forceInstalled = slug: {
|
|
||||||
install_url = "https://addons.mozilla.org/firefox/downloads/latest/${slug}/latest.xpi";
|
|
||||||
installation_mode = "force_installed";
|
|
||||||
};
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.modules.desktop.firefox.enable = lib.mkEnableOption "Firefox as the desktop browser";
|
options.modules.desktop.firefox.enable = lib.mkEnableOption "Firefox as the desktop browser";
|
||||||
@@ -34,21 +34,22 @@ in
|
|||||||
SponsoredPocket = false;
|
SponsoredPocket = false;
|
||||||
Snippets = false;
|
Snippets = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
# An ad blocker, a password manager, and a video sponsor-skipper.
|
|
||||||
ExtensionSettings = {
|
|
||||||
"uBlock0@raymondhill.net" = forceInstalled "ublock-origin";
|
|
||||||
"78272b6fa58f4a1abaac99321d503a20@proton.me" = forceInstalled "proton-pass";
|
|
||||||
"sponsorBlocker@ajay.app" = forceInstalled "sponsorblock";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
profiles.default = {
|
profiles.default = {
|
||||||
isDefault = true;
|
isDefault = true;
|
||||||
|
|
||||||
# The Nord chrome theme is a declared extension setting, so home-manager
|
extensions = {
|
||||||
# owns the extension-settings store, overwriting runtime changes to it.
|
packages = with firefoxAddons; [
|
||||||
extensions.force = true;
|
ublock-origin
|
||||||
|
proton-pass
|
||||||
|
sponsorblock
|
||||||
|
];
|
||||||
|
|
||||||
|
# The Nord chrome theme is a declared extension setting, so home-manager
|
||||||
|
# owns the extension-settings store, overwriting runtime changes to it.
|
||||||
|
force = true;
|
||||||
|
};
|
||||||
|
|
||||||
# Stylix's Nord mapping paints the selected address-bar result a
|
# Stylix's Nord mapping paints the selected address-bar result a
|
||||||
# near-white grey, leaving its light text unreadable. Darken that one
|
# near-white grey, leaving its light text unreadable. Darken that one
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ in
|
|||||||
blur.enabled = cfg.blur;
|
blur.enabled = cfg.blur;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# XWayland clients render at the panel's native resolution instead of
|
||||||
|
# being raster-scaled by the compositor at the fractional monitor scale.
|
||||||
|
xwayland.force_zero_scaling = true;
|
||||||
|
|
||||||
animations = {
|
animations = {
|
||||||
enabled = true;
|
enabled = true;
|
||||||
bezier = [ "ease, 0.25, 0.1, 0.25, 1.0" ];
|
bezier = [ "ease, 0.25, 0.1, 0.25, 1.0" ];
|
||||||
|
|||||||
24
modules/desktop/steam.nix
Normal file
24
modules/desktop/steam.nix
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# Steam game launcher and runtime integration.
|
||||||
|
let
|
||||||
|
cfg = config.modules.desktop.steam;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.modules.desktop.steam.enable = lib.mkEnableOption "Steam game launcher";
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
programs.steam = {
|
||||||
|
enable = true;
|
||||||
|
package = pkgs.steam.override {
|
||||||
|
extraEnv.STEAM_FORCE_DESKTOPUI_SCALING = "1.5";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
hardware.steam-hardware.enable = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ in
|
|||||||
|
|
||||||
image = wallpaper;
|
image = wallpaper;
|
||||||
|
|
||||||
|
# Regreet is not enabled, so its styling target stays off.
|
||||||
|
targets.regreet.enable = false;
|
||||||
|
|
||||||
cursor = {
|
cursor = {
|
||||||
package = pkgs.bibata-cursors;
|
package = pkgs.bibata-cursors;
|
||||||
# Solid white with a dark outline, so it stays easy to spot against the
|
# Solid white with a dark outline, so it stays easy to spot against the
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ in
|
|||||||
"class<chromium.*>" = g "f268";
|
"class<chromium.*>" = g "f268";
|
||||||
"class<[Cc]ode>" = g "f121";
|
"class<[Cc]ode>" = g "f121";
|
||||||
"class<obsidian>" = g "f02d";
|
"class<obsidian>" = g "f02d";
|
||||||
|
"class<[Ss]team>" = g "f1b6";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ in
|
|||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
home-manager.users.${user}.programs.direnv = {
|
home-manager.users.${user}.programs.direnv = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
silent = true;
|
||||||
|
|
||||||
# nix-direnv caches the evaluated shell so re-entering a directory is instant
|
# nix-direnv caches the evaluated shell so re-entering a directory is instant
|
||||||
# instead of re-running `nix develop`, and adds the `use flake` stdlib helper.
|
# instead of re-running `nix develop`, and adds the `use flake` stdlib helper.
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ in
|
|||||||
];
|
];
|
||||||
|
|
||||||
shellAliases = {
|
shellAliases = {
|
||||||
ls = "eza -al --color=always --group-directories-first --icons=always";
|
ls = "eza -alg --color=always --group-directories-first --icons=always";
|
||||||
la = "eza -a --color=always --group-directories-first --icons=always";
|
la = "eza -a --color=always --group-directories-first --icons=always";
|
||||||
ll = "eza -l --color=always --group-directories-first --icons=always";
|
ll = "eza -lg --color=always --group-directories-first --icons=always";
|
||||||
lt = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
|
lt = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
|
||||||
"l." = "eza -a | grep -e '^\\.'";
|
"l." = "eza -a | grep -e '^\\.'";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ in
|
|||||||
home-manager.users.${user} = hm: {
|
home-manager.users.${user} = hm: {
|
||||||
programs.nixvim = {
|
programs.nixvim = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
nixpkgs.useGlobalPackages = true;
|
||||||
|
|
||||||
extraPackages = with pkgs; [
|
extraPackages = with pkgs; [
|
||||||
git # neogit and gitsigns shell out to git
|
git # neogit and gitsigns shell out to git
|
||||||
|
|||||||
352
modules/ssh.nix
352
modules/ssh.nix
@@ -8,119 +8,298 @@ let
|
|||||||
cfg = config.modules.ssh;
|
cfg = config.modules.ssh;
|
||||||
user = config.user.name;
|
user = config.user.name;
|
||||||
|
|
||||||
|
inherit (lib)
|
||||||
|
concatLists
|
||||||
|
concatStringsSep
|
||||||
|
elem
|
||||||
|
filter
|
||||||
|
genAttrs
|
||||||
|
hasAttr
|
||||||
|
imap0
|
||||||
|
listToAttrs
|
||||||
|
mapAttrs
|
||||||
|
mapAttrsToList
|
||||||
|
mkIf
|
||||||
|
mkMerge
|
||||||
|
mkOption
|
||||||
|
nameValuePair
|
||||||
|
optional
|
||||||
|
optionalAttrs
|
||||||
|
types
|
||||||
|
unique
|
||||||
|
;
|
||||||
|
|
||||||
hostKeySecret = type: "ssh-host-${type}-key";
|
hostKeySecret = type: "ssh-host-${type}-key";
|
||||||
userKeySecret = "ssh-user-ed25519-key";
|
userKeySecret = "ssh-user-ed25519-key";
|
||||||
|
|
||||||
|
targetType = types.submodule (
|
||||||
|
{ name, ... }:
|
||||||
|
{
|
||||||
|
options = {
|
||||||
|
hostName = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = name;
|
||||||
|
description = ''
|
||||||
|
The network address OpenSSH connects to for this target.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = config.user.name;
|
||||||
|
description = ''
|
||||||
|
The remote login name OpenSSH uses for this target.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 22;
|
||||||
|
description = ''
|
||||||
|
The TCP port OpenSSH uses for this target.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
aliases = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [ name ];
|
||||||
|
description = ''
|
||||||
|
Host patterns written into the generated OpenSSH client block.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
clientKey = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
The public key this target offers when it connects outward.
|
||||||
|
Other machines admit this key according to the host groups below.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
hostKeys = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [ ];
|
||||||
|
description = ''
|
||||||
|
The public keys this target presents when it accepts inbound SSH.
|
||||||
|
These keys generate system-wide known-host entries.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
keyWithoutComment = key: concatStringsSep " " (lib.take 2 (lib.splitString " " key));
|
||||||
|
|
||||||
|
targetNamesIn = names: filter (name: hasAttr name cfg.targets) names;
|
||||||
|
|
||||||
|
workstationNames = targetNamesIn cfg.hosts.workstations;
|
||||||
|
serverNames = targetNamesIn cfg.hosts.servers;
|
||||||
|
|
||||||
|
clientKeysFor = names: filter (key: key != null) (map (name: cfg.targets.${name}.clientKey) names);
|
||||||
|
|
||||||
|
currentHost = config.networking.hostName;
|
||||||
|
isServer = elem currentHost cfg.hosts.servers;
|
||||||
|
isWorkstation = elem currentHost cfg.hosts.workstations;
|
||||||
|
|
||||||
|
defaultAuthorizedKeys = lib.flatten (
|
||||||
|
clientKeysFor workstationNames
|
||||||
|
++ optional isServer (clientKeysFor serverNames)
|
||||||
|
);
|
||||||
|
|
||||||
|
outboundTargetNames =
|
||||||
|
let
|
||||||
|
groupTargets =
|
||||||
|
if isServer then
|
||||||
|
[ "gitea" ] ++ cfg.hosts.servers
|
||||||
|
else if isWorkstation then
|
||||||
|
[ "gitea" ] ++ cfg.hosts.servers ++ cfg.hosts.workstations
|
||||||
|
else
|
||||||
|
[ "gitea" ];
|
||||||
|
in
|
||||||
|
filter (name: name != currentHost) (targetNamesIn groupTargets);
|
||||||
|
|
||||||
|
sshSettingsFor = name:
|
||||||
|
let
|
||||||
|
target = cfg.targets.${name};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
header = "Host ${concatStringsSep " " target.aliases}";
|
||||||
|
HostName = target.hostName;
|
||||||
|
User = target.user;
|
||||||
|
}
|
||||||
|
// optionalAttrs (target.port != 22) { Port = target.port; };
|
||||||
|
|
||||||
|
knownHostNamesFor = target:
|
||||||
|
let
|
||||||
|
names = unique (target.aliases ++ [ target.hostName ]);
|
||||||
|
withPort = name: if target.port == 22 then name else "[${name}]:${toString target.port}";
|
||||||
|
in
|
||||||
|
map withPort names;
|
||||||
|
|
||||||
|
knownHosts = listToAttrs (
|
||||||
|
concatLists (
|
||||||
|
mapAttrsToList (
|
||||||
|
targetName: target:
|
||||||
|
imap0 (i: key:
|
||||||
|
nameValuePair "${targetName}-${toString i}" {
|
||||||
|
hostNames = knownHostNamesFor target;
|
||||||
|
publicKey = keyWithoutComment key;
|
||||||
|
}
|
||||||
|
) target.hostKeys
|
||||||
|
) cfg.targets
|
||||||
|
)
|
||||||
|
);
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.modules.ssh = {
|
options.modules.ssh = {
|
||||||
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
|
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
|
||||||
|
|
||||||
workstationKeys = lib.mkOption {
|
hosts = {
|
||||||
type = lib.types.listOf lib.types.str;
|
servers = mkOption {
|
||||||
default = [
|
type = types.listOf types.str;
|
||||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia"
|
default = [ "pikachu" ];
|
||||||
];
|
description = ''
|
||||||
description = ''
|
Hosts that serve durable services.
|
||||||
Client public keys of the machines the operator works from.
|
They admit workstation keys and server keys, and they receive aliases for other servers and the forge.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
Every machine admits these, so any of them reaches the whole fleet.
|
workstations = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [ "neogaia" ];
|
||||||
|
description = ''
|
||||||
|
Hosts the operator works from.
|
||||||
|
Their keys are admitted by every host, and they receive aliases for the whole fleet and the forge.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
targets = mkOption {
|
||||||
|
type = types.attrsOf targetType;
|
||||||
|
default = {
|
||||||
|
gitea = {
|
||||||
|
hostName = "git.alexion.dev";
|
||||||
|
port = 2022;
|
||||||
|
user = "gitea";
|
||||||
|
aliases = [
|
||||||
|
"gitea"
|
||||||
|
"git.alexion.dev"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
neogaia = {
|
||||||
|
hostName = "10.23.50.146";
|
||||||
|
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia";
|
||||||
|
hostKeys = [
|
||||||
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia"
|
||||||
|
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== neogaia"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
pikachu = {
|
||||||
|
hostName = "10.23.10.102";
|
||||||
|
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu";
|
||||||
|
hostKeys = [
|
||||||
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu"
|
||||||
|
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
description = ''
|
||||||
|
SSH targets known to the fleet.
|
||||||
|
The inventory holds connection details plus public keys used for authorization and host verification.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
serverKeys = lib.mkOption {
|
extraAuthorizedKeys = mkOption {
|
||||||
type = lib.types.listOf lib.types.str;
|
type = types.listOf types.str;
|
||||||
default = [ ];
|
default = [ ];
|
||||||
description = ''
|
description = ''
|
||||||
Client public keys of the machines that serve.
|
Additional client public keys admitted by this host.
|
||||||
|
|
||||||
Only other servers admit these, so one that is compromised reaches no
|
|
||||||
machine the operator works from.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
authorizedKeys = lib.mkOption {
|
authorizedKeys = mkOption {
|
||||||
type = lib.types.listOf lib.types.str;
|
type = types.nullOr (types.listOf types.str);
|
||||||
default = cfg.workstationKeys;
|
|
||||||
defaultText = lib.literalExpression "config.modules.ssh.workstationKeys";
|
|
||||||
description = ''
|
|
||||||
Client public keys this machine admits for the primary user, drawn from
|
|
||||||
the lists above.
|
|
||||||
|
|
||||||
A machine the operator works from takes the workstation keys. One that
|
|
||||||
serves takes both, so servers reach each other. The default admits the
|
|
||||||
workstation keys, since a machine admitting none is unreachable.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
hostKeys.restore = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = true;
|
|
||||||
description = ''
|
|
||||||
Restore the host keys from secrets rather than letting the daemon
|
|
||||||
generate its own.
|
|
||||||
|
|
||||||
A machine with its own identity keeps its fingerprint across a reimage
|
|
||||||
by restoring committed keys. A guest carries no host identity, so it
|
|
||||||
turns this off and presents a self-generated key instead.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
hostKeys.sopsFile = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.path;
|
|
||||||
default = null;
|
default = null;
|
||||||
description = ''
|
description = ''
|
||||||
Encrypted file holding this host's SSH host private keys, one entry per
|
Complete override for client public keys admitted by this host.
|
||||||
key type, named `ssh-host-<type>-key`. Required when `restore` is on.
|
Leave null to derive access from `modules.ssh.hosts` and `modules.ssh.targets`.
|
||||||
|
|
||||||
These are the keys the daemon presents to identify itself to connecting
|
|
||||||
clients, not keys used to authenticate anyone to a remote server.
|
|
||||||
Restoring them from secrets rather than generating them keeps the host's
|
|
||||||
fingerprint across a reimage, so every client's `known_hosts` entry
|
|
||||||
stays valid.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
hostKeys.types = lib.mkOption {
|
extraSettings = mkOption {
|
||||||
type = lib.types.listOf lib.types.str;
|
type = types.attrsOf types.anything;
|
||||||
|
default = { };
|
||||||
|
description = ''
|
||||||
|
Additional OpenSSH client settings merged into the generated Home Manager configuration.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
hostKeys.restore = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = true;
|
||||||
|
description = ''
|
||||||
|
Restore the host keys from secrets rather than letting the daemon generate its own.
|
||||||
|
A machine with its own identity keeps its fingerprint across a reimage by restoring committed keys.
|
||||||
|
A guest carries no host identity, so it turns this off and presents a self-generated key instead.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
hostKeys.sopsFile = mkOption {
|
||||||
|
type = types.nullOr types.path;
|
||||||
|
default = null;
|
||||||
|
description = ''
|
||||||
|
Encrypted file holding this host's SSH host private keys, one entry per key type, named `ssh-host-<type>-key`.
|
||||||
|
Required when `restore` is on.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
hostKeys.types = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
default = [
|
default = [
|
||||||
"ed25519"
|
"ed25519"
|
||||||
"rsa"
|
"rsa"
|
||||||
];
|
];
|
||||||
description = ''
|
description = ''
|
||||||
Key types to restore, naming both the entries read from the encrypted
|
Key types to restore, naming both the entries read from the encrypted file and the algorithms the daemon offers.
|
||||||
file and the algorithms the daemon offers. Dropping a type a client has
|
|
||||||
already pinned makes the host unrecognisable to it.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
userKey.sopsFile = lib.mkOption {
|
userKey.sopsFile = mkOption {
|
||||||
type = lib.types.nullOr lib.types.path;
|
type = types.nullOr types.path;
|
||||||
default = null;
|
default = null;
|
||||||
description = ''
|
description = ''
|
||||||
Encrypted file holding this machine's SSH client private key, under the
|
Encrypted file holding this machine's SSH client private key, under the entry `ssh-user-ed25519-key`.
|
||||||
entry `ssh-user-ed25519-key`. Left unset on a machine that authenticates
|
Left unset on a machine that authenticates to no remote server, such as a guest.
|
||||||
to no remote server, such as a guest.
|
|
||||||
|
|
||||||
This is the key the primary user offers to authenticate to a remote
|
|
||||||
server, not a key the daemon presents to identify this machine.
|
|
||||||
It belongs to this machine alone, so withdrawing its access does not
|
|
||||||
re-key any other.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable (
|
config = mkIf cfg.enable (
|
||||||
lib.mkMerge [
|
mkMerge [
|
||||||
{
|
{
|
||||||
services.openssh.enable = true;
|
services.openssh.enable = true;
|
||||||
|
|
||||||
# The primary user is the only account reachable over SSH.
|
users.users.${user}.openssh.authorizedKeys.keys =
|
||||||
users.users.${user}.openssh.authorizedKeys.keys = cfg.authorizedKeys;
|
if cfg.authorizedKeys != null then
|
||||||
|
cfg.authorizedKeys
|
||||||
|
else
|
||||||
|
defaultAuthorizedKeys ++ cfg.extraAuthorizedKeys;
|
||||||
|
|
||||||
|
programs.ssh.knownHosts = knownHosts;
|
||||||
|
|
||||||
|
home-manager.users.${user}.programs.ssh = {
|
||||||
|
enable = true;
|
||||||
|
enableDefaultConfig = false;
|
||||||
|
settings =
|
||||||
|
mapAttrs (name: _: sshSettingsFor name) (genAttrs outboundTargetNames (name: name))
|
||||||
|
// cfg.extraSettings;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
# A machine with its own identity restores its host keys from secrets.
|
(mkIf cfg.hostKeys.restore {
|
||||||
(lib.mkIf cfg.hostKeys.restore {
|
|
||||||
assertions = [
|
assertions = [
|
||||||
{
|
{
|
||||||
assertion = cfg.hostKeys.sopsFile != null;
|
assertion = cfg.hostKeys.sopsFile != null;
|
||||||
@@ -128,42 +307,27 @@ in
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
# The daemon reads its host keys once at startup, so a re-key has to
|
sops.secrets = genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
|
||||||
# restart it to take effect.
|
|
||||||
sops.secrets = lib.genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
|
|
||||||
inherit (cfg.hostKeys) sopsFile;
|
inherit (cfg.hostKeys) sopsFile;
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
restartUnits = [ "sshd.service" ];
|
restartUnits = [ "sshd.service" ];
|
||||||
});
|
});
|
||||||
|
|
||||||
# An empty list is what stops the daemon generating keys of its own.
|
|
||||||
services.openssh.hostKeys = [ ];
|
services.openssh.hostKeys = [ ];
|
||||||
services.openssh.extraConfig = lib.concatMapStrings (
|
services.openssh.extraConfig = concatStringsSep "" (
|
||||||
type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n"
|
map (type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n") cfg.hostKeys.types
|
||||||
) cfg.hostKeys.types;
|
);
|
||||||
})
|
})
|
||||||
|
|
||||||
# The client key the primary user offers to remote servers, present only on
|
(mkIf (cfg.userKey.sopsFile != null) {
|
||||||
# a machine that has one.
|
|
||||||
(lib.mkIf (cfg.userKey.sopsFile != null) {
|
|
||||||
# The primary user is the only account that authenticates with this key,
|
|
||||||
# and the mode admits no other.
|
|
||||||
# The client rereads it per connection, so no unit restarts on a re-key.
|
|
||||||
sops.secrets.${userKeySecret} = {
|
sops.secrets.${userKeySecret} = {
|
||||||
inherit (cfg.userKey) sopsFile;
|
inherit (cfg.userKey) sopsFile;
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
owner = user;
|
owner = user;
|
||||||
};
|
};
|
||||||
|
|
||||||
# The client reads the decrypted key where it is written, so no copy of it
|
home-manager.users.${user}.programs.ssh.settings."*".IdentityFile =
|
||||||
# lives in the user's home to drift from the secret.
|
config.sops.secrets.${userKeySecret}.path;
|
||||||
# Declaring no defaults of home-manager's own leaves every other directive
|
|
||||||
# at the one OpenSSH itself ships.
|
|
||||||
home-manager.users.${user}.programs.ssh = {
|
|
||||||
enable = true;
|
|
||||||
enableDefaultConfig = false;
|
|
||||||
settings."*".IdentityFile = config.sops.secrets.${userKeySecret}.path;
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
72
modules/zfs.nix
Normal file
72
modules/zfs.nix
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# The host-level ZFS pool import: durable service state a host mounts, never rebuilds.
|
||||||
|
let
|
||||||
|
cfg = config.modules.zfs;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.modules.zfs = {
|
||||||
|
enable = lib.mkEnableOption "importing durable ZFS pools that hold service state";
|
||||||
|
|
||||||
|
hostId = lib.mkOption {
|
||||||
|
type = lib.types.strMatching "[0-9a-f]{8}";
|
||||||
|
example = "deadbeef";
|
||||||
|
description = ''
|
||||||
|
This host's 8-hex-digit ZFS host id, written to `networking.hostId`. ZFS
|
||||||
|
stamps an imported pool with the importing host's id, so a pool still
|
||||||
|
held by another machine is refused rather than silently dual-mounted. It
|
||||||
|
must be fixed for the machine and distinct across machines that can reach
|
||||||
|
the same pool.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
pools = lib.mkOption {
|
||||||
|
type = lib.types.attrsOf (lib.types.attrsOf lib.types.path);
|
||||||
|
default = { };
|
||||||
|
example = lib.literalExpression ''
|
||||||
|
{
|
||||||
|
tank = {
|
||||||
|
media = "/srv/media";
|
||||||
|
downloads = "/srv/downloads";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
'';
|
||||||
|
description = ''
|
||||||
|
The ZFS pools to import at boot, keyed by pool name, each pool mapping a
|
||||||
|
dataset path relative to it to that dataset's mountpoint. A pool is
|
||||||
|
durable state imported as it stands, never created or destroyed by a
|
||||||
|
rebuild, so a service's data survives any rebuild or reimage. A pool
|
||||||
|
with an empty map is still imported, leaving each dataset to its own ZFS
|
||||||
|
`mountpoint` property.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# The ZFS stack in the kernel and boot, needed even where the root filesystem is another kind.
|
||||||
|
boot.supportedFilesystems = [ "zfs" ];
|
||||||
|
|
||||||
|
# ZFS refuses to import a pool without a host id to stamp its ownership onto.
|
||||||
|
networking.hostId = cfg.hostId;
|
||||||
|
|
||||||
|
# The declared pools are imported at boot, distinct from any pool backing the root filesystem.
|
||||||
|
boot.zfs.extraPools = lib.attrNames cfg.pools;
|
||||||
|
|
||||||
|
# Each declared dataset is mounted at its host path as a native ZFS filesystem.
|
||||||
|
fileSystems = lib.mkMerge (
|
||||||
|
lib.mapAttrsToList (
|
||||||
|
pool: mounts:
|
||||||
|
lib.mapAttrs' (
|
||||||
|
dataset: mountpoint:
|
||||||
|
lib.nameValuePair mountpoint {
|
||||||
|
device = "${pool}/${dataset}";
|
||||||
|
fsType = "zfs";
|
||||||
|
}
|
||||||
|
) mounts
|
||||||
|
) cfg.pools
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
27
secrets/pikachu.yaml
Normal file
27
secrets/pikachu.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
ssh-host-ed25519-key: ENC[AES256_GCM,data:nxj7nMDzeqr0CitIjns9ugFh7Sur0pVpBr8SgWbxQMpIeNvBp+ugmnAxaUq2HRoYCo68uh/YKJZgMCBPSWrEyIhUX0rXmiZFmusrviJSXrYmy+n2XvLPjuv4vPbJEfjudRdXkHPazB3JJbeVRhGgK7y7smhNZlr8lwPGo0Ui0jVqWB4MPQRP6dLKVfV5shIusOqdhg7y2JSkZLYIjOBOJjknCxGmr5+vF2qwpDJQWEyzuJv2QJ48IlU7QyHF76//6xOchteL70/lyXI4gLiGl2fEczx3KKIXE+DNetKVAQxh4QHwzz9JK8qIJMYVry2qFdGGgF8eN7YEJYA60r1pL++1l8Uq++C1Bsc9jCO91ayQb+spXK0uM3G/ZJka7brRQbvcr54MJEyH6+0uBscXw/wMJ1EBX3Np6dumQhKuALnsAIXE89bKJg1wyPwaCy8uMZMcjQhvNkG0FPSBatHz2bB7bnNuNiXyT+scYyN0huE4Ccq7/H3bHWqbipV51YBH/XZKmjDiCwu3i0t5MHmp,iv:9P6Pa3hBe0/jbacjvzV3VGJJ4+OLRarEJEkLMOftqPw=,tag:zA6ewbj2ovP9+2WcAXa9qA==,type:str]
|
||||||
|
ssh-host-rsa-key: ENC[AES256_GCM,data:C/xmdAeQlQou4wbP1EBqZ8G5/dRpmGYprj/z1g68WfCO7L+gzIgOryaEhBHugv3USAN8E3Ak4Ry+aUZCzDuIM2OeWdUm2AVT6r5o1RPYFtHSdsJUbKKB+9T3bR9GgFBRyRInSOjQhqR3lYuFLeA1xWhV8UG3Vv1tJwjLV1/JTUQf9j/NcYUr8tqjqD6hB8Fa5IEHPAUviX40R50o+EoiPIkB+qKbGru0Eg7p5K+/6d858nZc2lNgXIFnh72D0hXdWFs1BvcwGaY2WL3i7nJmOBUc3zM3/bYlU9EIscl04V6aUSXmF2sqI13Psa8lApV08MdAyUoFSHlYxlFxqe0IJ3nm0ofbJdwM6oDR6E8yQeU9vpirnYOPI6CWx6Y83LFWAVlhOZQKoLPBbiKImjM8oyoBGiIdqUrIXggoZ1u4F2/SwJtd9MwNr0+UDQWAQsYoAoBfI9LSUvEj52C2RiF28MDw2mEE8zmuHWM96KRUB7E8sGXHJrfBzDC5JuVdwmNCAOgck7ACJnHh8ZbKQYv8iGjbwVU/9z52BIUTLjJrdllSmIGWjVqOSVAUZVl7R3AK/xAaUuNNLaNzR4kk/+lQhPTuOLeFBfWbu3kryfeyJNyuHHtS21tgTcwc/bMYVq8LM8ttPn3b/BLQMl49qlBNmMk4TZ4hzBC5BMW6TgSHJRXZ7eajV8x+TM+aGu34xU0TiIy95XgTdi1ktQ0kAzB94iLTsMVhWpwoOguOlGLPGDKuDlym44T4cFRHySSHy0xlroPD1FPSvlSP7uY++55HsxX/88M8HNtCQ9h2P7jFRBYL+CpYbwgWNqtIWq8/daxCxScKZZ2/sAvFL5Ttqe//wYwNp6F3WbIxOk7yS/Ov+ADOt+RnxB2P11dkWNN7kHscGO7/sBNhDbzyEB8kHdfbEYKAl+OwUUsYqiGzdKF8E8aNa/0AAld3whrVgsUmIbFeoAkvJO6TkiZ3uuJAHdF9L8XfpWLEbu+geb6HG4+Bf5QOxxKVkSihMCimTI5vPGHuXj0/vwMihsMvhcaTmfSh2cZRAQhwIPQamEEeC+vqU2Igwcm57Cg81gMJYORLj+SaZSbCbrUxqElE/1OxUtF1KEh9njXjNK/grKnZbDDfRrhZwm3Nri1yxQxk/haHVoZttwXKo2vsWsYvpaHPJuUoQ0r/Wo8RffH2s04qauphLgWi2BUpfs+nNFBGRu6S92o/dktNQYgVeAGo7fYrM4xQX26fjQcOR7TD5bQIR2HcfHzyfH6A4c8MuInUeuf7kZFOoR1DdUFXYbvNcIgbmB/ScImkP3q18KaH+JRsVJbEEB7X4YOQ6lvHTtk0QVLv50V22BA9naFlpKGMWjq0oT5Gf40c6+opDlQWKrgwISwDvm9QJznvquj1EUlFJwVf0SJBHfeomFITZZMma2no2EIV0s/n9LvaU0b1Aim9EAqxUTgMrryLzE16dqSQKugbsn2jWMy7By3Xf8g0kstbyht5EOUZpThrwthEcGRN3xRHHke6ILckAVqEvl2wMuNMV8sd904pdYUL1wQAHKtg+fRbzu5cMY8itEs6YPsWob+HaD+n2tfHf4ByR1KBZ92nCyAWWjW6jrSDCzYCRsxvMSdsBJz9U4szSExC2qm9kQxGeedOl8/qECRZQQArBQZdavbwECGVUT3LRWQOu6bDyFgzoTzUlRxK05wr+Rpir/NmiGURGCZhmy+J2QnejlK0zOidORZAkzFcuGUPiUDGvJ99y43oSYAEYAI4UMKu42zimdVz50PbD2jpSJS6OmxUKfPfyy6CKC1lmYSB7PVGHzQ2aIw0RVMND9JNcntD5qgS2/g1CFhh9z9QyVtkOqiPICXTRXmVEQ5PW04tyzKBRkiSPoXxpC1zq77QPUAz0ZfeElqppdGyfejRiRl6m5JXJ9Pwl3/pgMEqGs0gpM/+TuT3rrYr6v602wPNq6/1XWpizReVPJZNzMrmxQwiIO285w1kvBlZ1VQnbAUiJR+pZuc9PiegSqEbfdkC8SQaGj+2M9tSDRzDGDE1y8pOLDi8Wczbn313JM+QwYDAXGccXxCEq0d3fVjb26u0ZNq2nn5zDy002aMWro+mrsLlWOf4MoAwvnyxgwEewrBEFIk1n14huFd1YpVomZuY3Y9XX1b1XoiTlhbmlP2HopobgQCD4Gt4zFb9RovNxBib+g4j2N94uoiuXOyBTT1B/HWHr6DnMxNvtHIyMOUPzR+6hwO9ntmdhPh+UZIB2b6FWZWLec55yBewzmvWn44dOdo7Z8Tp4d7vfSamyQDO49LA7Bd0CpDan7lbpw5rD4iejRCqZ/yJHTRyExSrZtcLQsScaorGCPlxHL4MUlRMynkyjPSUDRzqdJs+OnJDfUxJ566z8978rCppuYGRAGCqnoEHN9vcGsncMt9LuyUFlDaNKoOFZlMthvfQnUy47JnrP5u18Ph8qjn6A2Wh6MowsMvM67EBSlx8ZdHJC1iWqlHULW7FKPtcvKnDB/GToOqi2y9+EuMVWpoDJmTTyZFr9fmMlA0mL9tXWWG5h3G4LeLrT+LmRmk5tgL1TUJz5XDRQIQZRXxP+X7yt1bDh5PQK0vyupnWbs8KGDM+/pO8GwkwPnehBmJkkfkA6W2DIWzqRZHstdGmk1vtBqE6yAmwBOuXL+vm0vN86ulN+87LNR3OpCBRyorqundmw82vg/RdPNuA+Zxqg+VErsuTTdJmgZxN1rDU+8AiJPC8ZK7mH74lLYX2Ad86ZDriPcAi7wyYLc1qrSSHFCofb2FuM3/cJXcadSjZ//HxZh6qVOC5FW3b/YnFLZeqUgai6PO5rvzgyXusb2qHK60sEyS6pfw+ybOyZxWAc1TYqFXtOhUje8SW3R4cCG4zyCh5kUjZLiCNoNU2MIvVHjSfe9KAvTO2pcmuDIuVv9CKoko8tgoFqS80UDeP0pn99AmnTMXbhUR7eYjR9qAABF0IJJGUUIR1SbwiJrdEXil1qNcgciBI1epIXc1uBRXhcEnHI8VxVwYxF5wISjfuvp4cqJk03AkZVa4E2yydUATOCtk6fkaxVJIkitQaVgc0tqS4pX+9d35NF/njFHXksT/wMBfVJYmcgfIHJFK46igB/rwwC3PpOM+YNM8aJpLyZbbUB9CEWMtT9P5LqLWFoUaC2brIKG7vrfy2OSNOEWxiEDAzn7kY1+zpOb93J8FURpDNV+K4YUznPr1z2BxEGAKQXXx2PHQpAYYvdane5uWsZWMTsK5qQunX2UHPscr3T0A384pzXNdgnR6OrZfbcIpmvTAjxH9qzQLj49kJQ98czuutJWxgrp9J7abKeIdWGObTra5gA6bJ5kw9FZjW0xiyrOLAok/Vbmen8WCIVxToMJnXR91uuq58GxrcuZFke0NpCfEu3bpdPeKlb+sy0FREiFCpaDUNFFn5DqTNwOiW5KjGw/fG6dqC/NUhH5xdeod4dR1C4NEpYKzbWolUFf+rvFtGrRnQ0VQMXav+fpnBFiRA/ez/7oAzzwNv8Y7s3QpCUSUjsPRGWG/4pDlRSaz7rwkCfRxpkbOmPruYx3fcHigBnvIMLtKAPCx4jE7EsS6BoHNhgCMRyzkdl+MZFLKZ7JgFvgPdNcieFlptax2KaEwBIvhgQ4rimLRCv9cCM1qmmNIGB0UovqZ0Ye8tRG6LteFTgsfb3bNQ5sShaHQiuh2m9JGnI41q8CH2Gn6Mz/ftxn4yDWL/SlkjhtnRLoYvD5ro/Tf4q+exobXC0fbA1sLOnaX3atqHeQWK4Sk9L7zrDbwrJHzgmFD2PUPUhpfg9r1QMtJVkUckhQgyjrrIBfyZ4qqPmpYbH1w7cfE3C/rdprNZFKujYlA0ol8Ok1n0gyRFTeNZvd5EdrQ3D+hIKc0ID7/FmfzIccrI6x+wOE1ePQPzHay2mM8qRcjgTiQOTMaFRYjVy28ovqgixs/bylGvw5VNMGJTIqcu6fC54i3HOBhmnH1Ab/5a7PmwXUbAajw+mKddtrr0wQ7cXRncFnoJtld2AIOs7aD51C1Zkr1UqxliWObCsXJmaQoOpBScHdiKNp1mKQ1Oddu3mj0Su1e4gQR+YAwOyQ1zBU5WvNBbudUmRIsjXDS2Y6XaVN50neQ0DZ5V71259xPyawyVvrdiiUg7HOXgGFGcWbFU+p7MZaq0KE3aXgwCC+/mhFgaKgIVWir5pElNaXLUMDF+1jnblaKyHyAk0HPBgegAphVqWF3Hqp3b15jTSdWsmOugGcCO9hLk2OD99nZzuEVBLbDg0yGISPgzVNFg3+Pqcu8FqmGxLwAWNTRc0+ov7VL5Y4fJoD4QOPj19+nZyvXze/FOYo1Hp+U7PBZDIKVswcdauZG+Z2t2bEd0OxXj1NoazpycIVI/TmiTVyDsK+ijXztNnWXGG24WW1KZ3O5+MmDm7F7RDi1vCOfOOg3Y1yPVprOvltyn1uPo5Q/pPTD1FmAGpJkmdT3+dKkb9N5u6RNvRBEzFY8HCqcvKX1HulpJpz72,iv:vfjzz6U6ey9wGs6Ia3Vd5HaGBvpSq4akp4e3eLC/A3M=,tag:ar1+i09oOEusEuQnr44ASw==,type:str]
|
||||||
|
ssh-user-ed25519-key: ENC[AES256_GCM,data:uHZADD2tq8vqlOAOWYniEnj1sl2IUqepf4YvrmRX/0nTksVnoethW6Yb2VNTgu5FsKCxvO65qMZ9KYupDkw/+SCZP2VH4TyhByQSr4SYxrEiNOJLdvmL2VnOWlNNZKaswgBePQR7TBG4mSIiTXCjEAcxeHAhBxLPnwiFxK/Zd6yQhq61x1N+ZtOj0q0QpADP3vioUohdkE5ODoEOUUoQJkYpjdJl2j4PhjCq7yHMEZpeMkqjZhVX5+i8blBu43BM4bKS2tBIJW2IvPV9sHOH8/tsR6p5q0pnpcJAOyOquBSo+eR0egY+liXjSv+Vo/ZDPapl7rKs2SoJ5huUGFbWjxbXY2+t2SYyiVA8lufrVUu94RCwOOJ8UyGYzAGGrChUImVDBsgk/GWwpOvWxcqydF8d6mDdxK9Iek9HtYsS+IKXLrF8GAI9OLB9jKgHVZgyQYSnC146cwCLt99TOFVocE1lfzwpFQTwKvxkep5jBEWiyqRaRUngti0JuF0ih1BWo+LKqxIqq9+V3I39xM8PfTjgHiifnxj4wuhS,iv:32zDKpNPYMiP6Rx7L9TAhxrMwcmphIGni+0jcESoLqg=,tag:YWGE62WQNm2Cer+eG+S5Xg==,type:str]
|
||||||
|
sops:
|
||||||
|
age:
|
||||||
|
- enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyTTVLWHVaZ1NxbGEzcVBq
|
||||||
|
MmorSkhzQ2hSV01idWE4VXNyNkNvMmpkTHpzCnFDQ0l3a1o1OWhmdE9WTjlob3p4
|
||||||
|
Y3BzMlJmeks4ZllzbEY1MkVqRCtrNjgKLS0tIFVhQXpDRjRUSEViMUhPM0V2RGRt
|
||||||
|
NkRpV05YV2RaTGozTUNNM3U5dmJ3dWsKSzkXmVyPhwN58SjMYYL/YbPoHYtZ9goF
|
||||||
|
VYRLRnfU298/3R/1nLNqTlMk4MYQ6NaGf2jOYTyObGgJVccOHF6wig==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
recipient: age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
||||||
|
- enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBGMDBPcHZ3YithYm5sM2Vh
|
||||||
|
NzJUVmpuT1FRdmw1bzI0a2p5NXFlZlRQN25NCmduQ1NKREVZT3Q5d0l4ZC9ZVUFU
|
||||||
|
all2UUZUMjZyaGxGMm5UOEQwcnNKS2sKLS0tIExPZmR5K05WZHRYRHZEeW1PRWhV
|
||||||
|
ZTZzTlhnYURJTDROTFpUTmFKc3ZkeXMKEWUCkVeHEt/Ay0lyAVjsqtLbu+pJOTJ5
|
||||||
|
dyjzy0/9Ui1IIXH36AImO5hiEZcrGePV2qPLLQc7ZLngC1jlJuEKJA==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
|
||||||
|
lastmodified: "2026-08-03T01:26:32Z"
|
||||||
|
mac: ENC[AES256_GCM,data:w4i6+dwN/t/SvFTrP/YiapxOY6ecT7+cJ0v/5lYMe4m1vL2w4mJw9ZrxWifcCcTEfaa766Wc1NE+GxGrBZtlORExbtQCa4tg2GuIQCRqTEc5WSqhYrgMb4elWuYe+r3/SlAf1ldNxz+cmLVtbDpSs96bvkGsKdw/i+q9n2URJYE=,iv:MFWgMtIdpn1v0T6FPhTgBMxi+6kzf2ZAnOGaisRT0d0=,tag:qBX32/zUHSPagXU+u8G8tA==,type:str]
|
||||||
|
unencrypted_suffix: _unencrypted
|
||||||
|
version: 3.13.3
|
||||||
@@ -3,22 +3,31 @@ sops:
|
|||||||
age:
|
age:
|
||||||
- enc: |
|
- enc: |
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBVYzZuaERsRjMyaTAwL3Ri
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvdGQ0ZnZ2UlVaTFVuRmJI
|
||||||
b2RhaGZ1aHNOSzVMamVxWkdKb3VKTk9QMmk4CmV0R3hYN3hkMU1tVDJLNkFlT08y
|
aVZNVjZVNGdiVGVhUTVXN3luU0V1eUZFb0ZRCkVrY3lKRE9GTUEzV0ZjUnk4UHdl
|
||||||
SUdUeUZ4d2JwNmdyOWVJcmZNcEtCb1EKLS0tIHhDV1NZWWdDZUNMYjVqYUVlc0ty
|
QnFMVDhMcGVUTWFQemxrSnNEMStpSEEKLS0tIFFIQndkZHp1NkRmRlB4RXlwdk5C
|
||||||
Y1owUFZPMXBHbDhjVWxTUjZGRk1IUzQK7VENq6TjuOFlon+CJqUxbIJZ9qka78C/
|
cTJVQW9iMWhaMlk1dUxBK1ZvQTRuV0kKm7/z24q4NcDFlVuxZViDFlJodjRzRqhY
|
||||||
LDsgaTD+7zCBPgASwPbF88pH6tdK7bvNLJnznlZdZBL12eOy25BmOQ==
|
7X9LqouIXcGhDgwq0hh+JXRfYCz9LDiUJtOLHR7Lu/oscCBCnk7N6Q==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
recipient: age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
recipient: age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
|
||||||
- enc: |
|
- enc: |
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHY0ZXT3lRWS9DMFA1MHhl
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTOENXYmRRNmVQeUtvOVFy
|
||||||
MDZiWHhEMy9INGtpd1ZOdzh0OFRoUlZDa0hZCkJwTUV4c01YWlE1QjNDd3pRN3F0
|
eElWVUhnUnJDTk93aVJCVmpTcHd2aVpKSFhJCk9oUk5ObHNSZHNwSisrSE9JbjRI
|
||||||
SGJWWmFTT1NMQktNejVHY1RrRlZJNFEKLS0tIHlRZG9ZV3FrQktSN2tURVV1NmlW
|
WVllODlYek1VMDZpMmh3M2JRTU92ZEkKLS0tIFVVNG9RNnlzS0ZvMUM3bVN2Mldo
|
||||||
UTBZbFlqMmFGZ0VPSlA1dmNMU2Q3TFUKtL2V8t9+Qw5vjXursvCVRatflX8JKXJr
|
N0MvcXEraDcwUHVxbTJqWGdxTGhOVG8KIhIY9QGbt/eWy9bfST4tEkjLQLylaHRm
|
||||||
VuA8oe0nKpk7wh4fCzcT7RoRKpJY0gPFjIzeTZGVfoAmZIUWMhzRuw==
|
AwYIwU1Hw6HXR1TX3t0YciI3c8HcWISruY/tBR3xIHIdBlQR5OTaeA==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
recipient: age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
recipient: age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
|
||||||
|
- enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4ODgyNjQ1RFMwOGpUaG5U
|
||||||
|
UDNjTENrYzY5UFVTNlRONlBhNmVUUXk2OVd3ClFQUUhFaUM4YmR6dVNlMXRwWUFY
|
||||||
|
ais3L0FyQ1ozWjFMMHhjWmU1NU5JdVUKLS0tIG1tS1NoZjhBb3JRak9XdEpOT3Vy
|
||||||
|
Uk5HdUd6NUsrZWx2Y1lrWGdHRXcxZEUK470gSumRCpgYvIWJcmylw0VTgyV3et/B
|
||||||
|
QkVLBz5x+ShVun27nN3oz8And0qXfwgXojhM3yWnBSUa9CFBsbTOJQ==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
|
||||||
lastmodified: "2026-07-20T03:22:00Z"
|
lastmodified: "2026-07-20T03:22:00Z"
|
||||||
mac: ENC[AES256_GCM,data:ei7PKVAIjJ6fGkxqJFc5wdYapq1gElel3fTJ+yKhvWHU+39aKcllG66T3d9FitRztgyt69phykHdKvxDHRUwYeyl1YBzyf1ZpPU5mXJb+hkLtVB1Am7StcP+m7jFqKSmqtYhIT9OxUrH0MJ8qeoU9216otwkhhpPz2hr1s7KYFk=,iv:Pp03KmlinjJiiTZezr0LzzkcHb1a5XWgDpu38jhl9Rk=,tag:HAqFitge+KTCtDE24t8/ig==,type:str]
|
mac: ENC[AES256_GCM,data:ei7PKVAIjJ6fGkxqJFc5wdYapq1gElel3fTJ+yKhvWHU+39aKcllG66T3d9FitRztgyt69phykHdKvxDHRUwYeyl1YBzyf1ZpPU5mXJb+hkLtVB1Am7StcP+m7jFqKSmqtYhIT9OxUrH0MJ8qeoU9216otwkhhpPz2hr1s7KYFk=,iv:Pp03KmlinjJiiTZezr0LzzkcHb1a5XWgDpu38jhl9Rk=,tag:HAqFitge+KTCtDE24t8/ig==,type:str]
|
||||||
unencrypted_suffix: _unencrypted
|
unencrypted_suffix: _unencrypted
|
||||||
|
|||||||
Reference in New Issue
Block a user