Compare commits
1 Commits
main
...
018d4becd1
| Author | SHA1 | Date | |
|---|---|---|---|
| 018d4becd1 |
53
.claude/CONTEXT.md
Normal file
53
.claude/CONTEXT.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
**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.
|
||||||
|
_Avoid_: framework, core, base, scaffolding
|
||||||
|
|
||||||
|
**Auto-loader**:
|
||||||
|
The lib code that recursively discovers and imports every Module under `modules/` (and every Host under `hosts/`) 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
|
||||||
14
.claude/adr/0001-sops-nix-for-secrets.md
Normal file
14
.claude/adr/0001-sops-nix-for-secrets.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
25
.claude/adr/0002-admin-and-host-age-identities.md
Normal file
25
.claude/adr/0002-admin-and-host-age-identities.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
34
.claude/adr/0003-hyprland-compositor.md
Normal file
34
.claude/adr/0003-hyprland-compositor.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
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).
|
||||||
24
.claude/adr/0004-module-namespace-mirrors-directory.md
Normal file
24
.claude/adr/0004-module-namespace-mirrors-directory.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
37
.claude/adr/0005-stock-firefox-policy-extensions.md
Normal file
37
.claude/adr/0005-stock-firefox-policy-extensions.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
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 +0,0 @@
|
|||||||
../.agents/skills
|
|
||||||
122
.claude/spec/firefox.md
Normal file
122
.claude/spec/firefox.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Neogaia now boots into the keyboard-driven Hyprland desktop, but the session ships no web browser.
|
||||||
|
The operator lives in this desktop daily and needs a browser, yet a stock browser install would arrive un-themed, telemetry-on, cluttered with sponsored surfaces, and requiring a round of manual clicking to reach a usable state.
|
||||||
|
That manual state would also be invisible to the flake and would not survive a reimage or transfer to the future desktop Host.
|
||||||
|
|
||||||
|
The operator wants the browser configured the same way as the rest of the system: declared once, hardened and themed by default, and reproduced automatically on any Host that runs the desktop.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Add Firefox as a new single-purpose desktop Module, configured entirely through home-manager's `programs.firefox`, and fold it into the desktop aggregator so the browser is part of the daily-drivable session rather than a separate opt-in.
|
||||||
|
|
||||||
|
Ship stock mainline Firefox, hardened and de-monetized through locked enterprise policies, with a small fixed set of extensions force-installed by policy from Mozilla's add-on site.
|
||||||
|
Default search to DuckDuckGo over a lean, pruned engine list.
|
||||||
|
Theme the browser Nord from the same single Stylix source that themes the rest of the graphical layer, with no hand-maintained browser CSS.
|
||||||
|
Register Firefox as the system default handler for web links.
|
||||||
|
|
||||||
|
Leave the most personal, frequently-changing state — bookmarks and container tabs — to Firefox's own runtime management rather than declaring it, keeping the Module lean and avoiding the destructive overwrite those declarative options impose.
|
||||||
|
|
||||||
|
Because the Module joins the aggregator, it comes up on any Host with the desktop enabled: neogaia now, and the future desktop Host for free, with no per-Host browser flag.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
1. As the operator, I want a web browser present the moment the desktop comes up, so that a daily-drivable session includes browsing without a separate install step.
|
||||||
|
2. As the operator, I want the browser expressed as one more enable in the desktop aggregator, so that any Host running the desktop inherits it and the future desktop Host adopts it without rework.
|
||||||
|
3. As the operator, I want the browser configured declaratively alongside every other Module, so that it is reproduced identically on reimage and never depends on manual post-install clicking.
|
||||||
|
4. As the operator, I want to stay on current mainline Firefox rather than an older release train, so that I get up-to-date browser features and security without maintaining an unusual package variant.
|
||||||
|
5. As the operator, I want a fixed set of extensions installed and actually enabled automatically, so that ad-blocking, password management, and sponsor-skipping work on first launch with no add-on hunting.
|
||||||
|
6. As the operator, I want ad and content blocking, so that pages are lighter and less hostile.
|
||||||
|
7. As the operator, I want my password manager available in the browser, so that credentials autofill without me reaching for another app.
|
||||||
|
8. As the operator, I want sponsor segments skipped in videos, so that watching is uninterrupted.
|
||||||
|
9. As the operator, I want telemetry, studies, the read-it-later widget, and the sponsored surfaces on the new-tab and address bar turned off and kept off, so that the browser is quiet, private, and un-monetized without me policing settings.
|
||||||
|
10. As the operator, I want the browser to stop offering to save logins and to stop nagging about being the default, so that it does not fight the password manager or interrupt me.
|
||||||
|
11. As the operator, I want Firefox accounts and sync disabled, so that no account surface appears for a feature I do not use.
|
||||||
|
12. As the operator, I want DuckDuckGo as the default search with only a lean set of engines present, so that search is private and uncluttered.
|
||||||
|
13. As the operator, I want the browser themed Nord from the same source as the rest of the desktop, so that it coheres with the bar, launcher, and lock screen without me hand-theming it.
|
||||||
|
14. As the operator, I want the browser registered as the system default for web links, so that links opened from the bar, notifications, the launcher, or the terminal land in it.
|
||||||
|
15. As the operator, I want bookmarks and container tabs left to the browser itself, so that the things I add in the moment are never wiped by a rebuild.
|
||||||
|
16. As the operator, I want the whole Host to still build green under the existing check, so that I gain confidence before switching a live machine.
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
**Module and placement**
|
||||||
|
|
||||||
|
- A new single-purpose Module is added under the desktop group, namespaced to mirror its location per the Namespace convention, exposing one `enable` option guarded by the Enable convention.
|
||||||
|
- The Module is configured entirely through the primary user's home-manager `programs.firefox`, matching every other user-facing Module in the repo; no NixOS-level Firefox program integration and no manual package override are used.
|
||||||
|
- The desktop aggregator turns the Module on at default priority alongside the terminal and the other session pieces, so a single desktop flag brings the browser up while a Host retains the ability to override it.
|
||||||
|
The browser is treated as an essential application of the session rather than optional plumbing, following the precedent that the aggregator already enables the terminal.
|
||||||
|
|
||||||
|
**Package variant and extension mechanism**
|
||||||
|
|
||||||
|
- The package is stock mainline Firefox, not ESR, unbranded, or Developer Edition.
|
||||||
|
- Extensions are installed through Mozilla's enterprise policy `force_installed`, keyed by add-on id with an install URL, so the browser fetches the signed add-on from Mozilla's add-on site and enables it automatically.
|
||||||
|
- Nix-built or hash-pinned add-on packages are not used, because stock Firefox refuses unsigned locally-built add-ons; the trade-off — losing build-time reproducibility of the extension binaries in exchange for current mainline Firefox with add-ons that are actually enabled and no new flake input — is accepted deliberately.
|
||||||
|
This decision is the pivotal, hard-to-reverse one and is called out for an ADR in Further Notes.
|
||||||
|
|
||||||
|
**Extensions**
|
||||||
|
|
||||||
|
- Three extensions are force-installed: an ad and content blocker, the operator's password manager, and a video sponsor-skipper.
|
||||||
|
- All three are self-contained web extensions, so no native messaging host is wired.
|
||||||
|
|
||||||
|
**Hardening**
|
||||||
|
|
||||||
|
- Hardening is split across two mechanisms by intent: things with a corresponding enterprise policy are set as locked policies so they cannot be toggled back in the UI, and the remainder are set as ordinary profile preferences.
|
||||||
|
- Locked policies turn off telemetry and studies and data reporting, turn off the read-it-later widget, stop the browser offering to save logins, stop the default-browser check, strip the sponsored shortcuts, sponsored stories, and snippets from the new-tab page, and disable Firefox accounts and sync.
|
||||||
|
- Profile preferences turn off sponsored address-bar suggestions and tidy the new-tab surface.
|
||||||
|
- Fingerprinting resistance is deliberately left off, because it breaks enough everyday browsing to be a deliberate per-need choice rather than a baseline.
|
||||||
|
|
||||||
|
**Search**
|
||||||
|
|
||||||
|
- A single profile is declared, named as the default profile.
|
||||||
|
- The default engine is DuckDuckGo, and the engine list is pruned to a lean set with the general-purpose commercial engines removed; the removed engines remain reachable through DuckDuckGo's bang syntax.
|
||||||
|
- Declaring search requires the module's authoritative-overwrite acknowledgement, so engines added later through the UI are not preserved across a rebuild; this is accepted as the point of declaring search.
|
||||||
|
|
||||||
|
**Theming**
|
||||||
|
|
||||||
|
- The browser is themed by enabling the Stylix Firefox target against the declared profile, driven from the same single Nord scheme that themes the rest of the graphical layer.
|
||||||
|
- No hand-written browser chrome CSS is shipped; a small chrome-CSS layer remains a later addition on top of Stylix if deeper chrome restyling is ever wanted.
|
||||||
|
- The Firefox Stylix target is enabled from within the browser Module, mirroring how the theming Module already sets per-Module Stylix targets, so the target only takes effect when the browser is enabled.
|
||||||
|
|
||||||
|
**Default browser**
|
||||||
|
|
||||||
|
- Firefox is registered as the default handler for the web-link schemes and HTML through the primary user's home-manager mime-association config, placed in the browser Module.
|
||||||
|
|
||||||
|
**Existing conventions already satisfied**
|
||||||
|
|
||||||
|
- The Waybar workspace indicator already carries a window-rewrite icon mapping for Firefox, so the graphical-application icon convention needs no change.
|
||||||
|
|
||||||
|
## Testing Decisions
|
||||||
|
|
||||||
|
- A good test asserts externally-observable evaluation and build success of the whole Host, not the internals of the Module.
|
||||||
|
This mirrors the desktop and laptop-MVI stance, where the config-merge model makes the whole-Host build the meaningful unit and the highest available seam.
|
||||||
|
- Primary seam, required and existing: the neogaia Host evaluates and its system toplevel builds under the flake check.
|
||||||
|
Building the toplevel drives the Auto-loader discovering the new Module, the aggregator fan-out, home-manager wrapping the Firefox package with the policies, extensions, search, and preferences baked in, the Stylix Firefox target wiring, and the default-handler association, surfacing nearly all config-authoring errors short of launching the browser.
|
||||||
|
- Cheap targeted checks: evaluate specific configuration paths to confirm the aggregator fans the browser enable out, the rendered policies carry the three force-installed extensions, the default search engine resolves to DuckDuckGo, and the Stylix Firefox target is on, reusing the repo's existing lightweight eval-probe pattern.
|
||||||
|
- No Module-level unit tests are added; there is no seam below the whole-Host build worth testing here, and the prior art is the desktop and laptop-MVI build-the-toplevel checks.
|
||||||
|
- The genuine end-to-end confirmation is manual and irreducible: switch the configuration on neogaia, launch the browser, and confirm the three extensions are present and enabled, the Nord theme is applied, DuckDuckGo is the default search, and a link opened from another application lands in the browser.
|
||||||
|
A browser cannot self-test headless, but unlike a reimage this is reversible, so verification is done by living in it with a safety net of a generation rollback or the desktop flag.
|
||||||
|
- The build validates that the policy document is well-formed and baked into the package, but not that Firefox accepts every policy key semantically, since the policy document is only text until the browser loads it; that last mile is part of the manual confirmation.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- ESR, unbranded, and Developer Edition Firefox variants, and the nix-built or hash-pinned add-on path they would enable; the reproducibility trade-off was weighed and declined.
|
||||||
|
- Native messaging hosts of any kind, including a password-manager native connector and desktop browser-integration bridges; none of the chosen extensions need one.
|
||||||
|
- Declarative bookmarks and declarative container tabs, both deliberately left to the browser's own runtime state.
|
||||||
|
- The Multi-Account Containers extension and the contextual-identity preference it relies on.
|
||||||
|
- Multiple Firefox profiles; a single default profile carries everything.
|
||||||
|
- Custom or Nix-oriented search engines beyond the lean pruned set.
|
||||||
|
- Hand-written browser chrome CSS and any deep chrome-layout restyling such as compact tabs or a hidden title bar.
|
||||||
|
- Fingerprinting resistance and any harder privacy posture that routinely breaks everyday browsing.
|
||||||
|
- Firefox accounts and sync.
|
||||||
|
- Any second browser, and any per-Host browser divergence; the future desktop Host inherits this same Module unchanged.
|
||||||
|
- Any Skeleton, Auto-loader, or secret-wiring change; the Module uses the existing plumbing unchanged.
|
||||||
|
|
||||||
|
## Further Notes
|
||||||
|
|
||||||
|
- The stock-Firefox-plus-policy-extensions decision is hard to reverse — the variant dictates the entire extension mechanism — surprising without context, since a Nix-purist default would expect hash-pinned add-on packages, and the product of a real trade-off between reproducibility and current mainline Firefox with working extensions.
|
||||||
|
It should be recorded as an ADR.
|
||||||
|
- Home-manager's `programs.firefox` was confirmed to expose a top-level policies option that merges into the wrapper's enterprise policies, which is what lets the whole Module, hardening included, live under home-manager rather than needing the NixOS-level program integration.
|
||||||
|
- The declarative search, bookmarks, and containers options all share one authoritative-overwrite model: the browser owns those files at runtime, so declaring them means home-manager overwrites them wholesale and runtime-added entries do not survive a rebuild.
|
||||||
|
This is why bookmarks and containers are left out and why declaring search is an explicit acknowledgement.
|
||||||
|
- The extension set fetches signed add-ons from Mozilla's add-on site at runtime rather than from the Nix store, so first launch after a fresh build needs network to populate the extensions; this is inherent to the policy-based path chosen over the pinned-package path.
|
||||||
173
.claude/spec/hyprland-desktop.md
Normal file
173
.claude/spec/hyprland-desktop.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Neogaia currently boots to a console only.
|
||||||
|
The laptop minimum viable install deliberately deferred everything graphical, so the operator works entirely from the terminal (fish, tmux, nvim, Claude Code) with no desktop to live in.
|
||||||
|
|
||||||
|
The operator wants a graphical desktop, and it must be primarily keyboard-driven.
|
||||||
|
Their mental model is i3: numbered workspaces, `Super`+number to switch, manual tiling.
|
||||||
|
They type on a 60% keyboard where arrow keys and the navigation cluster live behind a layer, so a keyboard-first workflow that never reaches for those keys matters.
|
||||||
|
|
||||||
|
The same desktop has to transfer to the future desktop Host (zeus), which must game well and stay low-friction in general.
|
||||||
|
The operator previously ran KDE Plasma 6 bent into an i3 imitation and valued above all that it "just worked", but remembers past i3/Sway minimalism costing hours of fixing random breakage.
|
||||||
|
That concern is now largely mitigated: NixOS makes the whole stack declarative, pinned, and reversible, and the agent absorbs the discovery and debugging that used to eat evenings.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Add a keyboard-driven Wayland desktop built on Hyprland, expressed as a new grouped set of Modules.
|
||||||
|
Enable it on neogaia now, and design it Host-agnostic so zeus adopts the identical desktop later by flipping a single flag.
|
||||||
|
|
||||||
|
Deliver a complete, daily-drivable session in one pass rather than a bare stub, because unlike the reimage this is fully reversible ("edit and rebuild"), so there is no safety reason to under-scope.
|
||||||
|
The session covers the compositor, a text login, Nord theming, a status bar, a launcher, notifications, lock and idle, a wallpaper, clipboard history, screenshots, screen recording, and the desktop portals, together with the operator's ported keybinds and input tuning.
|
||||||
|
|
||||||
|
Theme the whole graphical layer Nord from a single source (Stylix), scoped so it owns only the new graphical surface and leaves the existing terminal Modules' hand-themes untouched.
|
||||||
|
Leave gaming out entirely; it belongs to a future zeus-oriented Module, and neogaia is not a gaming machine.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
1. As the operator, I want neogaia to boot into a keyboard-driven Hyprland session, so that I can live in a graphical desktop without leaving my keyboard-first workflow.
|
||||||
|
2. As the operator, I want the desktop expressed as Host-agnostic Modules enabled by a single flag, so that zeus can adopt the identical desktop later without rework.
|
||||||
|
3. As the operator, I want a text-based login, so that I log in mouse-free without a heavy graphical display manager.
|
||||||
|
4. As the operator, I want the whole graphical layer themed Nord from one source, so that GTK, Qt, the bar, the lock screen, and the launcher cohere without me hand-theming each, while my existing nvim, tmux, and fish themes stay exactly as they are.
|
||||||
|
5. As the operator, I want a fast modern terminal launched on `Super+Return`, so that my tmux and nvim stack has a clean frame and my old muscle memory for opening a terminal carries over.
|
||||||
|
6. As the operator, I want a status bar showing workspaces with per-application icons, clock, battery, network, audio, media controls, and a do-not-disturb toggle, so that I can read system state at a glance.
|
||||||
|
7. As the operator, I want a search-everything launcher covering applications, open windows, math evaluation, and emoji, reused as the frontend for clipboard history and a power menu, so that one keybound tool handles launching and utility menus.
|
||||||
|
8. As the operator, I want notification toasts with do-not-disturb and history recall, so that I see notifications and can retrieve ones I missed.
|
||||||
|
9. As the operator, I want a secure lock screen and idle management, so that going idle, suspending, or closing the lid always lands me at a locked screen, and the lock survives even if the locker process crashes.
|
||||||
|
10. As the operator, I want a single static Nord wallpaper, so that the desktop looks coherent with no extra moving parts and no battery cost.
|
||||||
|
11. As the operator, I want clipboard history picked through the launcher, so that I can paste from recent copies entirely by keyboard.
|
||||||
|
12. As the operator, I want keyboard-driven screenshots for a region, the active window, or the full screen that open in an annotation editor by default and land in both the clipboard and a file, so that capturing and marking up is a single reflex.
|
||||||
|
13. As the operator, I want a keybound screen recorder that selects a region and then toggles recording, so that capturing demos becomes a habit.
|
||||||
|
14. As the operator, I want screen sharing to work inside applications, so that video calls and browser screen-share function.
|
||||||
|
15. As the operator, I want my keybinds ported from my KDE/i3 scheme but expressed entirely in `hjkl` and letters with no arrow or navigation-cluster keys, so that every binding is reachable on my 60% keyboard.
|
||||||
|
16. As the operator, I want numbered-workspace bindings, so that my i3 muscle memory of `Super`+number to switch and `Super`+`Shift`+number to move carries over unchanged.
|
||||||
|
17. As the operator, I want Caps mapped to Escape, a US-only layout, snappy key-repeat, and touchpad tap-to-click, natural scroll, and disable-while-typing with flat mouse acceleration, so that input feels like home on the laptop.
|
||||||
|
18. As the operator, I want subtle animations with rounding and small gaps but no blur on the laptop, so that the desktop feels modern without draining the battery.
|
||||||
|
19. As the operator, I want the desktop built from granular single-purpose Modules grouped together with an explicit aggregator, so that a Host enables the whole desktop with one flag yet can still override any single piece.
|
||||||
|
20. As the operator, I want gaming deliberately excluded from this pass, so that neogaia's desktop stays focused and the gaming stack lands with zeus.
|
||||||
|
21. As the operator, I want the whole Host to still build green under the existing check, so that I gain confidence before switching a live machine.
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
**Direction and compositor**
|
||||||
|
|
||||||
|
- Hyprland is the compositor, on neogaia now and zeus later, chosen as a single keyboard-driven tiler that serves both Hosts.
|
||||||
|
It matches the operator's i3 workflow (numbered workspaces, manual tiling) while getting closest to "just works" among true tilers through its cohesive first-party companion tools and large ecosystem.
|
||||||
|
- The choice was made over Sway (its minimalism is the very thing that cost the operator evenings), KDE Plasma (mouse-first at heart), and niri (its scrollable-tiling paradigm abandons numbered workspaces and so breaks the operator's core muscle memory).
|
||||||
|
- The gaming/driver dimension does not discriminate here: neogaia is Intel and zeus is AMD, both of which drive Wayland flawlessly, so the decision rested on workflow and low-friction rather than on surviving a hostile driver.
|
||||||
|
This corrects a stale assumption in the laptop spec (see Further Notes).
|
||||||
|
- Hyprland is sourced from nixpkgs rather than the upstream Hyprland flake.
|
||||||
|
The NixOS-level program integration owns the session, portals, and polkit, and home-manager owns the user configuration, sharing one Hyprland package so there is never a version split.
|
||||||
|
The session is launched through the universal Wayland session manager from the greeter for clean systemd session and environment integration.
|
||||||
|
No compositor plugins are adopted this pass, which removes the main reason to take the flake; moving to the flake later is a contained change that mirrors the existing chaotic-nyx pattern (an input that must not follow nixpkgs, with its own binary cache).
|
||||||
|
|
||||||
|
**Session and theming**
|
||||||
|
|
||||||
|
- Login uses greetd with the tuigreet text greeter.
|
||||||
|
This is mouse-free and lightweight, avoiding a heavy graphical display manager and its Qt/GTK weight.
|
||||||
|
- Theming uses Stylix, scoped to the graphical layer.
|
||||||
|
A single Nord base16 scheme drives colors, system fonts, cursor, and the static wallpaper across the new graphical surface (GTK, Qt, bar, lock, launcher, notifications, compositor colors).
|
||||||
|
Stylix targets for the existing terminal tools (nvim, tmux, fish) are left off so their established hand-themes stand unchanged.
|
||||||
|
The terminal is themed by Stylix where its target is mature, with hand-written Nord as a fallback otherwise.
|
||||||
|
This decision is highly reversible: Stylix toggles per target, so the graphical layer can migrate toward or away from manual theming later at low cost.
|
||||||
|
|
||||||
|
**Components**
|
||||||
|
|
||||||
|
- Terminal: Ghostty.
|
||||||
|
- Status bar: Waybar, showing workspaces with per-application icons plus clock, battery, network, audio, MPRIS media controls, and a do-not-disturb toggle.
|
||||||
|
No overview/exposé plugin is used; the workspace indicators are sufficient.
|
||||||
|
- Launcher: rofi (the Wayland fork), combining application-run, binary-run, and window-switch modes into one prompt, plus math-evaluation and emoji modes.
|
||||||
|
It is reused as the dmenu-style frontend for clipboard history and a power menu, so one tool serves several jobs.
|
||||||
|
- Notifications: mako, with do-not-disturb and history recall.
|
||||||
|
Media controls and the do-not-disturb toggle live in the bar rather than in a separate notification center.
|
||||||
|
- Lock and idle: hyprlock and hypridle.
|
||||||
|
hyprlock uses the compositor session-lock protocol, so the lock surface is owned by the compositor and survives a locker crash.
|
||||||
|
hypridle is wired for lock-on-idle, screen-off, lock-before-suspend, and lid-close, with tunable timeouts.
|
||||||
|
- Wallpaper: a single static image set by Stylix.
|
||||||
|
- Clipboard: cliphist with wl-clipboard, storing text and image history, picked through rofi.
|
||||||
|
- Screenshots: grim and slurp wrapped by grimblast, routed through the satty annotation editor so that annotation is the default on region, active-window, and full-screen captures, each exporting to both the clipboard and a file.
|
||||||
|
- Screen recording: wf-recorder, region-select-first and video-only (no audio), toggled by a keybind with a bar recording indicator and notifications.
|
||||||
|
- Portals: the Hyprland desktop portal (screencast, screenshot, global shortcuts) plus the GTK portal (file dialogs and appearance).
|
||||||
|
Screen sharing in applications depends on these regardless of whether the recorder is present.
|
||||||
|
|
||||||
|
**Keybinds**
|
||||||
|
|
||||||
|
The scheme ports the operator's KDE/i3 bindings but is expressed entirely in `hjkl` and letters, with no arrow or navigation-cluster keys, so it is fully reachable on a 60% keyboard.
|
||||||
|
Bindings that were quick-tile-to-half on the floating KDE desktop are reclaimed for real tiling actions, because that gesture is meaningless in an automatic tiler.
|
||||||
|
|
||||||
|
| Action | Bind |
|
||||||
|
|---|---|
|
||||||
|
| Switch to workspace N | `Super`+`1`..`9` |
|
||||||
|
| Move window to workspace N | `Super`+`Shift`+`1`..`9` |
|
||||||
|
| Move focus | `Super`+`h`/`j`/`k`/`l` |
|
||||||
|
| Move window in layout | `Super`+`Shift`+`h`/`j`/`k`/`l` |
|
||||||
|
| Resize | `Super`+`Alt`+`h`/`j`/`k`/`l` |
|
||||||
|
| Terminal | `Super`+`Return` |
|
||||||
|
| Launcher | `Super`+`R` |
|
||||||
|
| Lock | `Super`+`X` |
|
||||||
|
| Toggle floating | `Super`+`Space` |
|
||||||
|
| Fullscreen | `Super`+`F` |
|
||||||
|
| Toggle split | `Super`+`T` |
|
||||||
|
| Close window | `Super`+`Shift`+`Q` |
|
||||||
|
| Force-kill | `Super`+`Ctrl`+`Q` |
|
||||||
|
| Screenshot region / window / full (to satty) | `Super`+`L` / `Super`+`Shift`+`L` / `Super`+`Ctrl`+`L` |
|
||||||
|
| Clipboard history | `Super`+`Shift`+`V` |
|
||||||
|
| Record toggle (region first) | `Super`+`Shift`+`R` |
|
||||||
|
| Volume / brightness / media | `XF86` hardware keys |
|
||||||
|
|
||||||
|
**Input**
|
||||||
|
|
||||||
|
- US-only keyboard layout, with no layout switcher.
|
||||||
|
- Caps mapped to Escape, with Shift+Caps still producing a real CapsLock.
|
||||||
|
- Key-repeat tuned snappy (a short delay before repeat begins, a fast repeat rate).
|
||||||
|
- Touchpad with tap-to-click, natural scroll, and disable-while-typing.
|
||||||
|
- Mouse with flat acceleration.
|
||||||
|
|
||||||
|
**Feel**
|
||||||
|
|
||||||
|
- Tasteful, subtle animations with modest rounding and small gaps.
|
||||||
|
- Blur is off on the laptop, where it is the single biggest battery cost; it remains a knob a Host such as zeus can enable.
|
||||||
|
|
||||||
|
**Module structure**
|
||||||
|
|
||||||
|
- The desktop is a set of granular, single-purpose Modules grouped under a desktop directory, each independently toggleable so pieces stay reusable and the files stay focused.
|
||||||
|
The tightly coupled Hyprland-native pieces (compositor, lock, idle) are grouped in a subdirectory within that group.
|
||||||
|
- An explicit aggregator Module turns the desktop on.
|
||||||
|
Guarded by its own enable, it sets each piece's enable at default priority so that a Host enables the whole desktop with one flag while retaining the ability to override any single piece.
|
||||||
|
The aggregator hand-lists the pieces it enables rather than scanning the directory, so a newly added file stays inert until deliberately added to that list; this keeps the whole desktop legible from one file.
|
||||||
|
- The desktop's enable options are namespaced under a single desktop group, so a Host's checklist gains one desktop entry and the aggregator itself reads as the sub-checklist of what that entry means.
|
||||||
|
- This uses the existing recursive Auto-loader as-is: every file in the group is imported unconditionally (inert until enabled), so no Skeleton change is needed.
|
||||||
|
The aggregator/namespaced-group pattern is a deliberate evolution of the flat Module plus Host-as-checklist convention (see Further Notes).
|
||||||
|
|
||||||
|
## Testing Decisions
|
||||||
|
|
||||||
|
- A good test asserts externally-observable evaluation and build success of the whole Host, not the internals of any individual Module.
|
||||||
|
This mirrors the laptop MVI's stance, where the config-merge model makes the whole-Host build the meaningful unit and the highest available seam.
|
||||||
|
- Primary seam (required, existing): the neogaia Host evaluates and its system toplevel builds under the flake check.
|
||||||
|
Building the toplevel drives the Auto-loader discovering the new Modules, the aggregator fan-out, home-manager integration, the full Stylix wiring, the Hyprland program integration, and package availability, surfacing nearly all config-authoring errors short of rendering a frame.
|
||||||
|
- Cheap targeted checks: evaluate specific configuration paths to confirm the desktop aggregator fans out, the Stylix scheme resolves to Nord, and Hyprland is enabled, reusing the repo's existing lightweight eval-probe pattern.
|
||||||
|
- No Module-level unit tests are added; there is no seam below the whole-Host build worth testing here, and the prior art is the laptop MVI's build-the-toplevel check.
|
||||||
|
- The genuine end-to-end confirmation is manual and irreducible: switch the configuration on neogaia, log in through the greeter, and exercise the live session (keybinds, lock, screenshot, clipboard, launcher).
|
||||||
|
A graphical session cannot self-test headless, but unlike the reimage this is reversible, so verification is done by living in it with a safety net (roll back a generation, drop to a console, or disable the desktop flag).
|
||||||
|
- VM-based graphical CI (boot assertions under a NixOS test) is deferred, consistent with the laptop MVI's stated stance.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- The gaming stack (Steam, gamescope, Proton, replay-buffer recording, OBS), which belongs to a future zeus-oriented Module.
|
||||||
|
- The zeus Host itself, and multi-monitor/output configuration, since there is no second display to design against yet; monitor focus and move bindings are deferred with it.
|
||||||
|
- Any non-Hyprland desktop; KDE, Sway, and niri were considered and rejected.
|
||||||
|
- The upstream Hyprland flake and compositor plugins, including workspace-overview/exposé, which are explicitly not adopted this pass.
|
||||||
|
- Migrating the existing nvim, tmux, and fish themes into Stylix; they stay hand-themed.
|
||||||
|
- Dynamic, animated, or video wallpaper, and wallpaper cycling.
|
||||||
|
- Screen-recording audio and a full-screen recording variant.
|
||||||
|
- A slide-out notification-center panel and a batteries-included desktop panel; both were considered and rejected in favor of the minimal, Stylix-coherent stack.
|
||||||
|
- Any change to the Skeleton or the Auto-loader; the structure uses them unchanged.
|
||||||
|
- Any secret wiring.
|
||||||
|
|
||||||
|
## Further Notes
|
||||||
|
|
||||||
|
- The compositor decision (Hyprland over Sway, KDE, and niri) is hard to reverse and the result of a real trade-off, so it should be recorded as an ADR.
|
||||||
|
- The aggregator plus namespaced-group Module pattern is a deliberate departure from the flat, Host-as-full-checklist convention, and is worth recording as a short ADR or a conventions note so its intent is not relearned.
|
||||||
|
- The laptop MVI's out-of-scope line attributing Nvidia to zeus is factually wrong: zeus runs an AMD GPU, and Raichu (a server with no desktop) is the only Nvidia machine.
|
||||||
|
The laptop MVI is a historical document and is left unchanged, so the accurate fact is recorded here and in ADR 0003 instead.
|
||||||
|
- The ported keybind scheme is derived from the operator's prior KDE Plasma 6 configuration (nine numbered desktops, `Super`+number bindings, a terminal on `Super`+`Return`, Caps mapped to Escape, and custom per-desktop tile layouts), which lives in this repo's git history under the old reference config.
|
||||||
|
- Neogaia is Intel and zeus is AMD, both of which drive Wayland without driver caveats; this is what lets one keyboard-first compositor serve both Hosts rather than forcing a per-Host divergence.
|
||||||
92
.claude/spec/laptop-mvi.md
Normal file
92
.claude/spec/laptop-mvi.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
I'm returning to NixOS after ~2 years away, and I want to start by moving my laptop (`neogaia`, a Dell XPS 13 9380 currently running CachyOS) onto it. My old config still exists but is stale and written in a style I no longer want to copy verbatim. Eventually this same config has to grow to cover my desktop and three servers, so whatever I build for the laptop has to be a clean, scalable foundation — not a throwaway.
|
||||||
|
|
||||||
|
Reimaging the laptop is destructive and I only get one machine, so I need a tightly-scoped, well-understood **minimum viable install (MVI)**: the smallest config that boots the laptop into a usable state I can then iterate on live, without risking a half-defined system that strands me at a dead console.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Rebuild the `Skeleton` and a single `neogaia` `Host` to the point where the laptop:
|
||||||
|
|
||||||
|
- boots from an encrypted disk (LUKS + btrfs + zram),
|
||||||
|
- comes up on wifi,
|
||||||
|
- lets me log into a console as my user and run `nixos-rebuild switch`,
|
||||||
|
- and already carries my core terminal tooling (fish, tmux, nvim, Claude Code).
|
||||||
|
|
||||||
|
Everything graphical and everything multi-host is deliberately left for later iterative passes, which are safe because a mistake then is "edit and rebuild," not "reimage." The MVI is the one step that must be right *before* reimaging; the rest is reversible.
|
||||||
|
|
||||||
|
The install itself is done from the NixOS live ISO by cloning the repo from my Gitea and running a single `disko-install` against the `neogaia` `Host`, then setting a bootstrap password by hand.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
1. As the operator, I want the `Skeleton` rewritten around my old scalable ideas (the `Auto-loader`, the `Enable convention`, per-`Host` layout), so that the config stays legible and shareable across all five future machines without me copying stale code.
|
||||||
|
2. As the operator, I want the flake hand-rolled and cleaned up (no framework layer), so that the whole plumbing stays readable in one place for a config that only targets a handful of `x86_64-linux` machines.
|
||||||
|
3. As the operator, I want every `Module` auto-discovered and imported but inert until a `Host` sets its `enable` flag, so that each `Host` reads as a checklist of features.
|
||||||
|
4. As the operator, I want a `nixos-unstable` base with an `unstable overlay` and a `stable overlay`, so that I can run rolling by default but reach up to bleeding-edge or down to rock-solid on a per-package basis.
|
||||||
|
5. As the operator, I want home-manager integrated as a NixOS module with global packages, so that one `nixos-rebuild switch` builds both the system and my user environment atomically.
|
||||||
|
6. As the operator, I want my user modelled as an explicit option defaulting to `alexion` (no impure environment lookup), so that the config is reproducible and honest about who the user is.
|
||||||
|
7. As the operator, I want the laptop's disk declared with `disko` as encrypted btrfs plus zram swap, so that the install is reproducible and the laptop is encrypted at rest.
|
||||||
|
8. As the operator, I want the system to prompt for the LUKS passphrase at boot via systemd-boot and the initrd, so that the encrypted disk unlocks on a normal boot.
|
||||||
|
9. As the operator, I want the CachyOS kernel from chaotic-nyx with the chaotic binary cache wired in from the first build, so that I get the performance/feel I'm used to without compiling the kernel from source.
|
||||||
|
10. As the operator, I want Intel microcode and the redistributable firmware for the QCA6174 wifi included, so that the laptop's hardware works out of the box.
|
||||||
|
11. As the operator, I want NetworkManager enabled, so that I can join wifi easily from the console.
|
||||||
|
12. As the operator, I want an SSH daemon running, so that I can drive the rest of the setup remotely if the console is inconvenient.
|
||||||
|
13. As the operator, I want my user in `wheel` with a manually-set bootstrap password, so that I can log in and use sudo on first boot without committing any secret to a public repo.
|
||||||
|
14. As the operator, I want fish as my default login shell, configured natively via home-manager with my `cachyos-config.fish` translated (greeting, bat-manpager, `done` and bang-bang plugins, helper functions, eza/nav aliases) and all Arch/pacman-specific parts dropped or replaced with NixOS equivalents, so that my shell feels like home but is correct for NixOS.
|
||||||
|
15. As the operator, I want tmux configured natively via home-manager using my exact existing `tmux.conf` text, so that my terminal multiplexer is identical to today with no plugin manager needed.
|
||||||
|
16. As the operator, I want my nvim config brought in verbatim (lazy.nvim managing its own plugins) via a writable out-of-store symlink, with `git`/`gcc`/`ripgrep`/`fd` provided by Nix, so that my editor is identical to today and lazy.nvim can still update and write its lockfile.
|
||||||
|
17. As the operator, I want Claude Code installed declaratively and authenticatable without a browser on the laptop, so that I can use it over the console/SSH via the paste-code flow or an API key.
|
||||||
|
18. As the operator, I want timezone `America/New_York`, locale `en_GB.UTF-8`, and console keymap `us` set, so that the base system matches my locale preferences.
|
||||||
|
19. As the operator, I want to install by cloning the repo from my Gitea onto the live ISO and running `disko-install` against `neogaia`, so that I avoid self-signed-TLS/auth problems with flake fetching during install.
|
||||||
|
20. As the operator, I want the `Skeleton` designed so that per-`Host` disk layouts, per-`Host` kernels, and preserved ZFS pools are all expressible, so that the same foundation extends to the desktop and the three servers later without restructuring.
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
**Skeleton**
|
||||||
|
- Hand-rolled flake, rewritten and trimmed; no flake-parts.
|
||||||
|
- `Auto-loader` rewritten: recursively discovers and imports every `Module` under the modules tree without the old null-placeholder traversal hack; a single discovery helper feeds the `Host` imports. The old `nixosModules` flake output is dropped.
|
||||||
|
- Helper lib trimmed to the `Auto-loader`, the host-builder, and the script-from-file helper. `with lib.my` replaced by explicit `inherit`s throughout. `enable` flags use the stdlib enable-option helper rather than bespoke sugar.
|
||||||
|
- `nixos-unstable` as the base channel. An `unstable overlay` exposes `nixpkgs-unstable` packages; a `stable overlay` exposes the latest stable release (`nixos-26.05`). chaotic-nyx added as an input with its overlay and binary cache from the start.
|
||||||
|
- home-manager sourced from `nix-community`, tracking master with nixpkgs followed, integrated as a NixOS module with global packages and user packages.
|
||||||
|
- User modelled as an explicit option defaulting to `alexion`, in `wheel`, driving the system user and the home-manager user in lockstep.
|
||||||
|
|
||||||
|
**neogaia Host**
|
||||||
|
- Disk declared via `disko`: LUKS-encrypted btrfs with subvolumes plus zram swap. systemd-boot on an EFI system partition; initrd LUKS unlock.
|
||||||
|
- CachyOS kernel selected via a small per-`Host` kernel mechanism; chaotic substituter and trusted key in the Nix settings.
|
||||||
|
- Intel microcode; redistributable firmware enabled for the QCA6174 wifi. NetworkManager for networking. A zram toggle `Module` enabled here.
|
||||||
|
- SSH daemon enabled. Baseline CLI (git, editor, flakes) present. Claude Code installed declaratively.
|
||||||
|
- fish `Module`: native home-manager configuration; translated aliases/functions/plugins/init; set as the default login shell. tmux `Module`: native home-manager, exact existing config text inlined. nvim `Module`: verbatim config placed as a writable out-of-store symlink with runtime dependencies provided by Nix.
|
||||||
|
- Locale, timezone, and keymap set to the detected values.
|
||||||
|
|
||||||
|
**Install flow**
|
||||||
|
- Repo pushed to Gitea first. From the NixOS live ISO: join wifi, clone the repo locally, run `disko-install` against the `neogaia` `Host` with the chaotic substituter passed to the install-time daemon, set bootstrap passwords via `nixos-enter`, reboot.
|
||||||
|
|
||||||
|
**Secrets (design only in MVI)**
|
||||||
|
- Per ADR 0001, secrets use `sops-nix` with age keys derived from each `Host`'s SSH host key. The MVI does not wire any secret, because a `Host`'s age key does not exist until its first install generates the SSH host key. The bootstrap password is set by hand and never committed; moving passwords to a `hashedPasswordFile` backed by a sops secret is the first post-boot task, out of scope here.
|
||||||
|
|
||||||
|
## Testing Decisions
|
||||||
|
|
||||||
|
- A good test here asserts externally-observable evaluation/build success of the whole `Host`, not the internals of any individual `Module`.
|
||||||
|
- **Primary seam (required):** the `neogaia` `Host` evaluates and its system toplevel builds. Building the toplevel drives the entire `Skeleton` — the `Auto-loader` discovering every `Module`, all three overlays resolving, home-manager integration, and every enabled module's config merging without conflict — plus the `disko` layout, which builds from the same tree. Nearly all config-authoring errors surface at this seam short of booting real hardware.
|
||||||
|
- No unit-level tests of individual modules; the config-merge model makes the whole-`Host` build the meaningful unit, and it is the highest available seam.
|
||||||
|
- Prior art: none in this repo yet (it starts empty); this build-the-toplevel check is the pattern to reuse for every future `Host`.
|
||||||
|
- The genuine end-to-end confirmation is the real reimage, which is manual and irreversible by nature and is not automated.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Any graphical environment: Wayland-vs-i3 choice, greeter/display manager, theming (Nord via Stylix or otherwise), fonts, terminal emulator, browser, general desktop apps, gaming (Steam/Lutris/proton-cachyos), emulation.
|
||||||
|
- Full `sops-nix` wiring and moving passwords off the bootstrap value (immediate post-boot follow-up, but not MVI).
|
||||||
|
- Migrating nvim to a native home-manager configuration with Nix-managed plugins.
|
||||||
|
- chaotic-nyx packages beyond the kernel (`mesa-git`, `proton-cachyos`, `scx` schedulers).
|
||||||
|
- Flatpak strategy (`nix-flatpak` vs dropping the old imperative helper).
|
||||||
|
- The desktop `Host` (`zeus`), including Nvidia.
|
||||||
|
- The three servers: deployment model, service migration (plex/arr/kavita/nfs/torrent-through-protonvpn), ZFS wiring and pool import, backups/monitoring, and per-server kernel/channel pinning.
|
||||||
|
- VM-based CI (`nixosTest` boot assertions) — explicitly a future addition, not part of this deliverable.
|
||||||
|
|
||||||
|
## Further Notes
|
||||||
|
|
||||||
|
- **Bootstrap ordering:** the flake must exist on Gitea before the install can consume it, and the manual password step keeps the public repo free of any secret while still yielding a login on first boot.
|
||||||
|
- **Gitea is a bootstrap dependency:** every NixOS install pulls the config from self-hosted Gitea, so the Gitea host must stay reachable during any install — relevant when sequencing the servers so the migration never locks the operator out of their own configs.
|
||||||
|
- **chaotic cache at install time:** the install-time Nix daemon on the live ISO must have the chaotic substituter configured, or it compiles the CachyOS kernel from source on the USB stick.
|
||||||
|
- **Extends to future Hosts by construction:** disk layout, kernel, and channel are all per-`Host` concerns in the `Skeleton`, and existing ZFS pools are preserved by import rather than declared through `disko`. This is what lets the desktop and the three servers join later without reworking the foundation.
|
||||||
|
- **Theme target is Nord** (the current CachyOS setup is Nord across terminal, tmux, and nvim), superseding the old repo's Dracula — relevant when the theming branch is grilled.
|
||||||
76
.claude/spec/pi-coding-agent.md
Normal file
76
.claude/spec/pi-coding-agent.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
I run Claude Code as my coding agent on `neogaia`, wired deeply into this flake: skills, a sudo-guard hook, shared agent instructions, and the whole `.claude/` workflow.
|
||||||
|
Pi is a young, fast-moving, self-modifying terminal coding agent that I want to evaluate as an alternative harness.
|
||||||
|
I need to install it on the laptop in a way that lets me try it honestly — same model, same account — without unpicking any of the Claude Code setup and without committing myself to Pi before it has earned a permanent place.
|
||||||
|
|
||||||
|
The evaluation only means something if the one variable under test is the harness itself, and if backing Pi out later is trivial.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Add a new `pi` Module that installs Pi for the primary user on `neogaia`, enabled by the Enable convention like every other feature.
|
||||||
|
It flips the home-manager `programs.pi-coding-agent` module on and freezes exactly one file — `settings.json` — pinning the provider and model so Pi runs the same brain as Claude Code (Anthropic, Opus) and disabling analytics so Pi never attempts a runtime write to that frozen file.
|
||||||
|
|
||||||
|
Everything else is left to Pi's own writable state directory (`~/.pi/agent/`): no agent context, no skills, no extensions, no keybindings, no custom model providers.
|
||||||
|
Pi authenticates by reusing my existing Claude subscription, and that credential is deliberately left unmanaged by the flake so no secret touches the repo and re-auth survives rebuilds — exactly as the `claude-code` Module already treats its login.
|
||||||
|
|
||||||
|
The result is a minimal, non-disruptive, side-by-side experiment: Claude Code stays the daily driver, Pi sits alongside it, and removing Pi is a one-line `enable` flip.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
1. As the operator, I want Pi installed as its own auto-discovered Module that stays inert until a Host enables it, so that Pi reads as one more `enable = true` line on `neogaia` and never rides silently onto future Hosts.
|
||||||
|
2. As the operator, I want Pi enabled only on `neogaia`, so that the experiment is contained to the machine I actually drive.
|
||||||
|
3. As the operator, I want Pi to run Anthropic's Opus by default, matching Claude Code's model, so that any difference I observe between the two is attributable to the harness and not the model.
|
||||||
|
4. As the operator, I want Pi's provider and default model pinned reproducibly in the flake, so that the same Pi configuration would rebuild identically on any Host.
|
||||||
|
5. As the operator, I want Pi's analytics disabled in that same pinned configuration, so that Pi never attempts the one runtime write it would otherwise make to the frozen settings file.
|
||||||
|
6. As the operator, I want Pi to authenticate by reusing my existing Claude subscription rather than a separate API key, so that the comparison hits the same account at zero marginal cost.
|
||||||
|
7. As the operator, I want Pi's credential left unmanaged by the flake, so that no secret is committed to a public repo and my authentication survives rebuilds.
|
||||||
|
8. As the operator, I want Pi installed with no agent context, no skills, and no extensions, so that I see Pi's native behaviour rather than a port of the Claude Code setup.
|
||||||
|
9. As the operator, I want Pi to keep full ownership of its writable state directory, so that its self-modifying behaviour — generated extensions, skills, prompt templates, installed packages, sessions — works unimpeded.
|
||||||
|
10. As the operator, I want Pi sourced from the base package set and bumped with the normal flake update, so that it stays reasonably fresh without a second package set evaluated for one tool.
|
||||||
|
11. As the operator, I want the Module laid out as a directory rather than a single file, so that promoting Pi later — adding rendered skills or extensions — is an additive change rather than a restructure.
|
||||||
|
12. As the operator, I want backing Pi out to be a single `enable` flip, so that an experiment that does not pan out leaves no residue.
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
**pi Module**
|
||||||
|
- A new Module under its own directory, following the shape of the existing `claude-code` Module, declaring a single `enable` option under the `modules` tree and guarding its body with the Enable convention.
|
||||||
|
- The directory layout (rather than a single file) is chosen so that later rendering of a skills or extensions source is an additive edit, not a move.
|
||||||
|
- On enable, the Module turns on the home-manager `programs.pi-coding-agent` module for the primary user. That upstream module ships the `pi-coding-agent` package (base package set) and manages the state directory's declared files.
|
||||||
|
- The Module freezes exactly one file through that upstream module's `settings` option: the default provider set to Anthropic, the default model set to Opus (the exact model-id string confirmed against Pi's own model catalogue at build time), and analytics disabled.
|
||||||
|
- No other upstream option is set: `context`, `models`, `keybindings`, `extraPackages`, and `configDir` are all left at their defaults, so home-manager renders nothing but `settings.json` into `~/.pi/agent/` and Pi owns every other path there.
|
||||||
|
- Rationale for the single frozen file: among the files the upstream module can render, `settings.json` is the only one Pi writes at runtime, and only its analytics keys — disabling analytics removes even that, so freezing it is safe and never fights Pi's self-modification, which targets other paths entirely.
|
||||||
|
- Credentials are out of the flake by design. Pi reuses the Claude subscription via its own login, and the resulting token lives under Pi's state directory, which home-manager does not overwrite — mirroring the `claude-code` Module's treatment of its login.
|
||||||
|
- Security posture is inherited, not added: the `claude-code` Module already widens the sudo credential cache system-wide for the primary user, so Pi's `bash` tool can spend a warm credential. Pi does not pass through Claude Code's cold-cache sudo guard, and no equivalent guard is added for Pi in this deliverable. This is an accepted, bounded posture for a supervised single-user experiment.
|
||||||
|
|
||||||
|
**neogaia Host**
|
||||||
|
- The `neogaia` Host enables the new Module with a single `enable = true`, alongside its existing feature list.
|
||||||
|
- No other Host is touched; the desktop and servers do not yet exist in the flake and would each opt in on their own terms.
|
||||||
|
|
||||||
|
## Testing Decisions
|
||||||
|
|
||||||
|
- A good test here asserts externally-observable evaluation and build success of the whole `neogaia` Host, not the internals of the Module. Installing a Module is config authoring, and the meaningful unit is the Host it composes into.
|
||||||
|
- **Primary seam (required, reused):** the `neogaia` Host evaluates and its system toplevel builds via `nix flake check` (the `checks.x86_64-linux.neogaia` target). Building the toplevel drives the Auto-loader discovering the new Module, the `programs.pi-coding-agent` home-manager integration resolving, the frozen `settings.json` rendering, and every enabled Module's config merging without conflict.
|
||||||
|
- No new seam is introduced. This is the single high seam that `laptop-mvi.md` established and that every Module in this repo is verified through; an install-a-Module feature does not justify a second one.
|
||||||
|
- No unit-level test of the Module in isolation. The config-merge model makes the whole-Host build the highest and most meaningful seam.
|
||||||
|
- Prior art: the existing `claude-code`, `gitea-axi`, `fish`, `tmux`, and `nvim` Modules are all verified this way — enabled on `neogaia`, exercised by the toplevel build.
|
||||||
|
- The genuine end-to-end confirmation — launching `pi`, authenticating against the Claude subscription, and running the agent — is a manual post-build action on the real machine and is not automated, consistent with how interactive login is handled for Claude Code.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Any harvest/promote pipeline for sharing Pi's self-modifications across machines — rendering a repo-held skills or extensions source into Pi's state directory. Deferred until Pi earns a permanent slot.
|
||||||
|
- A shared skill source between Pi and Claude Code for a fairer comparison, exploiting their common `SKILL.md` skill format.
|
||||||
|
- A Pi-specific sudo guard (or any tool-permission guard) built as a Pi extension.
|
||||||
|
- An `AGENTS.md` context carrying the operator's cross-agent house rules (commit conventions, no attribution trailer, markdown and filename rules). Its absence means Pi's commits will not automatically follow those conventions during the experiment; this is accepted.
|
||||||
|
- Custom model providers (`models.json`), custom keybindings, and any non-Anthropic provider.
|
||||||
|
- Moving Pi to the `unstable` overlay for head-of-channel freshness.
|
||||||
|
- Wiring the credential through a secret store; sops is not yet wired on this Host, and Pi follows the same unmanaged-credential path as Claude Code until it is.
|
||||||
|
- Enabling Pi on any Host other than `neogaia`.
|
||||||
|
- An ADR recording the "promote agent self-modifications into the flake rather than sync mutable agent state" stance. It is not enacted by this deliverable; if Pi is promoted and the harvest pipeline is built, that becomes a real, repo-wide decision — covering Claude Code too — worth recording then.
|
||||||
|
|
||||||
|
## Further Notes
|
||||||
|
|
||||||
|
- **Why the single frozen file matters:** Pi's real self-modification surface (generated extensions, skills, prompt templates, installed package code, sessions, trust decisions) lives in paths under `~/.pi/agent/` that the upstream home-manager module never manages, regardless of what the Module declares. Freezing `settings.json` therefore constrains none of it, and disabling analytics removes the only runtime write that file would otherwise receive.
|
||||||
|
- **Promotion path is left open by construction:** the directory-shaped Module and the untouched `configDir` mean that, if Pi sticks, a repo-held skills or extensions source can be rendered into the state directory with a writable-directory / read-only-leaf layout — the same mechanism this repo already uses for Claude Code skills — without restructuring anything decided here.
|
||||||
|
- **Fair-comparison intent:** matching the model (Opus) and the account (the Claude subscription) is deliberate, so the experiment isolates the harness. Choosing a lighter model or a separate key would introduce a second variable and blur the read.
|
||||||
|
- **Reversibility:** because only `settings.json` is frozen and the credential and all self-modification state live outside the flake, disabling the Module removes Pi cleanly, leaving Pi's own state directory as the only residue on disk.
|
||||||
148
.claude/spec/sops-secrets.md
Normal file
148
.claude/spec/sops-secrets.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
`neogaia` is up and running the NixOS it builds, but its login password was set by hand through `nixos-enter` during the install and lives only on that laptop's disk.
|
||||||
|
It is the one piece of the machine that is not declared, not reproducible, and not recoverable — a reimage loses it, and no other `Host` can inherit it.
|
||||||
|
|
||||||
|
The same gap blocks everything queued behind it.
|
||||||
|
An Anthropic API key cannot be provisioned declaratively, a WireGuard key cannot be committed, and the three planned servers cannot carry service credentials.
|
||||||
|
The repo is public and mirrored to GitHub, so none of that material can be committed in plaintext.
|
||||||
|
|
||||||
|
There is a second, subtler cost.
|
||||||
|
SSH host keys are currently generated fresh by `sshd` on each install, so reimaging any machine invalidates its host identity and breaks `known_hosts` for every client that ever connected to it.
|
||||||
|
|
||||||
|
`ADR 0001` chose `sops-nix` for this, but its stated mechanism — age keys derived from each `Host`'s SSH host key — turns out to be the wrong topology, and its consequences no longer describe what should be built.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Wire `sops-nix` into the `Skeleton` as unconditional plumbing, with a two-tier age identity model.
|
||||||
|
|
||||||
|
An **admin identity** stored outside the repo entirely, in Proton Pass, is a recipient of every secrets file.
|
||||||
|
It is the durable recovery path: it outlives every machine, is reachable from any device including a live ISO, and is the credential that authorizes adding a new `Host` as a recipient.
|
||||||
|
|
||||||
|
A **host identity** — a dedicated age key on each machine's encrypted root — is a recipient of only that machine's own secrets plus the shared file.
|
||||||
|
It is generated on the machine, never leaves it, and is deliberately not derived from the SSH host key, which is what frees the SSH host keys to become secrets in their own right.
|
||||||
|
|
||||||
|
Secrets are `sops`-encrypted into this same public repo.
|
||||||
|
The ciphertext is safe to publish, and the only artifact that would not be — the admin private identity — is never committed at all.
|
||||||
|
No second repository is introduced.
|
||||||
|
|
||||||
|
The first pass moves the login password off its hand-set value and makes `neogaia`'s SSH host keys stable across reimages.
|
||||||
|
That exercises both decryption paths — the early one that runs before user creation, and the ordinary activation one — proving the machinery end to end on the two secrets that are actually needed today.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
1. As the operator, I want my login password declared as an encrypted secret rather than typed into a running machine, so that it is reproducible and survives a reimage like everything else in the flake.
|
||||||
|
2. As the operator, I want secrets encrypted into the existing public repo rather than a separate private one, so that there is one repository to clone and no bootstrap credential is needed to reach my own configuration during an install.
|
||||||
|
3. As the operator, I want an admin identity held in my password manager and never committed, so that nothing brute-forceable is published and I can recover every machine from a device I have never used before.
|
||||||
|
4. As the operator, I want each `Host` to hold its own age identity, so that a compromised server cannot decrypt my laptop's secrets.
|
||||||
|
5. As the operator, I want a shared secrets file alongside per-`Host` ones, so that material common to every machine is stored once rather than duplicated five times.
|
||||||
|
6. As the operator, I want my workstation to be a recipient of only its own secrets, so that the admin identity stays a break-glass credential rather than something sitting unlocked on a laptop.
|
||||||
|
7. As the operator, I want `neogaia`'s SSH host keys stored as secrets and restored at activation, so that reimaging the laptop does not invalidate its host identity or break `known_hosts` for clients.
|
||||||
|
8. As the operator, I want the secrets machinery to live in the `Skeleton` rather than behind an `enable` flag, so that it reads as plumbing every `Host` depends on rather than an optional feature.
|
||||||
|
9. As the operator, I want individual secrets declared next to the configuration that consumes them, so that a reader finds the secret where they find its use.
|
||||||
|
10. As the operator, I want a mistyped secret name or a missing secrets file to fail the build, so that errors surface at `nix flake check` rather than at boot.
|
||||||
|
11. As the operator, I want the procedure for provisioning a new `Host`'s identity written down, so that installing the desktop and the servers does not require rederiving the key ceremony under pressure.
|
||||||
|
12. As the operator, I want the recovery path documented for a machine whose identity was provisioned wrongly, so that a failed first boot is a known procedure rather than an improvised one.
|
||||||
|
13. As the operator, I want the editing workflow documented, so that I know which secrets I can change from my laptop and which require unlocking the admin identity.
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
**Identity topology**
|
||||||
|
|
||||||
|
- Two tiers of recipient: one admin identity, plus one identity per `Host`.
|
||||||
|
Every secrets file is encrypted to admin and to whichever `Host`s legitimately read it.
|
||||||
|
- The admin identity is stored as a secure note in Proton Pass and is never committed in any form.
|
||||||
|
Only its public recipient appears in the repo.
|
||||||
|
No passphrase-encrypted copy is committed: the vault already provides passphrase protection with rate limiting, whereas a committed copy would be offline-brute-forceable by anyone who clones the repo, indefinitely.
|
||||||
|
- Each `Host` identity is a dedicated age key on the LUKS-encrypted root, generated on that machine and never transmitted.
|
||||||
|
It is *not* derived from the SSH host key.
|
||||||
|
Decoupling them is what allows the SSH host keys to be secrets themselves; deriving one from the other would be circular.
|
||||||
|
- An admin recipient on every file is structurally required, not a convenience.
|
||||||
|
A file readable only by its own `Host` becomes permanently unrecoverable the moment that machine is wiped, and adding any new recipient must be done by someone who can already decrypt.
|
||||||
|
|
||||||
|
**Repository layout**
|
||||||
|
|
||||||
|
- One repository, public, unchanged.
|
||||||
|
A private repository was considered and rejected: cloning it requires credentials that would themselves become bootstrap material at install time, reintroducing the hand-carried secret the design otherwise eliminates, in exchange for protecting content that is already safe to publish.
|
||||||
|
- A `sops` configuration file and a secrets directory at the repo root.
|
||||||
|
- One secrets file per `Host`, encrypted to admin plus that `Host`.
|
||||||
|
- One shared secrets file encrypted to admin plus every `Host`.
|
||||||
|
- `neogaia` is a recipient of its own file and the shared file only.
|
||||||
|
Editing another machine's secrets requires unlocking the admin identity for that session, which is the intended friction.
|
||||||
|
|
||||||
|
**Secrets in this pass**
|
||||||
|
|
||||||
|
- The primary user's password hash lives in the shared file, consumed through `hashedPasswordFile`.
|
||||||
|
It is marked as needed for users, which makes `sops-nix` decrypt it in an earlier activation stage than ordinary secrets, before accounts are created.
|
||||||
|
This is the one ordering subtlety in the design and is the reason the `Host` identity must sit on the root filesystem rather than anywhere later-mounted.
|
||||||
|
- Storing the password hash in the shared file rather than per-`Host` is deliberate.
|
||||||
|
The same password will be used on every machine, so duplicating the identical hash across per-`Host` files would not reduce what an attacker learns — it would only make rotation a five-file edit.
|
||||||
|
- `neogaia`'s SSH host **private** keys live in its own `Host` file, with `sshd`'s generated host keys disabled and pointed at the decrypted paths instead.
|
||||||
|
- SSH host **public** keys are committed in plaintext.
|
||||||
|
They are not secret — publishing them is their function — and encrypting them would impose a re-key cycle every time one changes.
|
||||||
|
|
||||||
|
**Placement in the flake**
|
||||||
|
|
||||||
|
- The machinery goes in the `Skeleton` as unconditional configuration, not behind an `enable` flag.
|
||||||
|
This is a deliberate departure from the `Enable convention`, on the same grounds as the overlays and the flakes settings: every `Host` will carry secrets, so the flag would be permanently `true`, and the plumbing is not a feature a `Host` chooses.
|
||||||
|
- The `Skeleton` carries only the machinery — the flake input, the identity file location, and the default secrets file.
|
||||||
|
Individual secrets are declared wherever they are consumed, so the password secret sits beside the user declaration it feeds and the SSH host keys beside the `sshd` configuration.
|
||||||
|
- `sops-nix` is added as a flake input following the base `nixpkgs`.
|
||||||
|
|
||||||
|
**Operational procedures**
|
||||||
|
|
||||||
|
- For `neogaia`, which is already installed and running, provisioning happens live: generate the identity on the machine, add its recipient, re-key the affected files with the admin identity, rebuild.
|
||||||
|
No reimage and no live ISO are involved.
|
||||||
|
- For a `Host` that does not yet exist, provisioning happens on the live ISO *before* the install: generate the identity, add its recipient, re-key, write the identity onto the target root, then install.
|
||||||
|
The first boot then has everything it needs and cannot fail for want of a key.
|
||||||
|
The install already builds from a local clone, so no push is required mid-procedure; the recipient change is committed afterward.
|
||||||
|
- Both procedures, the editing workflow, and the live-ISO recovery path are documented in the existing install document rather than a new one.
|
||||||
|
|
||||||
|
**Decision record**
|
||||||
|
|
||||||
|
- A new ADR supersedes `ADR 0001`, which is marked superseded.
|
||||||
|
`ADR 0001`'s choice of `sops-nix` over `agenix` still holds and carries forward in a sentence, but its key-derivation mechanism is replaced and its stated consequence — that each new `Host` registers its SSH host public key as a recipient — is inverted, since SSH host keys are now secrets rather than the root of trust.
|
||||||
|
|
||||||
|
## Testing Decisions
|
||||||
|
|
||||||
|
- A good test here asserts externally-observable build and activation behaviour, not the internals of `sops-nix`.
|
||||||
|
Nothing in this feature is our own logic to unit-test; it is configuration wiring, and the meaningful assertions are that the whole `Host` still evaluates and that the secrets actually materialize on a real machine.
|
||||||
|
- **Primary seam (required):** `nix flake check` building the `neogaia` system toplevel, the same seam the laptop MVI established.
|
||||||
|
It carries real weight for this feature rather than merely compiling: `sops-nix` validates secrets files at evaluation time by default, so a missing file, a file that is not valid `sops` output, or a declared secret whose key is absent from it all fail the build.
|
||||||
|
Mistyped secret names surface here rather than at boot.
|
||||||
|
- **Confirmation (manual):** a real activation on `neogaia`.
|
||||||
|
This is what proves decryption itself — that the `Host` identity is readable at the right stage, that secrets appear with the declared ownership and mode, that `sshd` adopts the restored host keys, and that login works against `hashedPasswordFile`.
|
||||||
|
It cannot be automated without a machine that holds a real identity, and is treated like the reimage in the laptop MVI: manual by nature.
|
||||||
|
- No new seams are introduced.
|
||||||
|
The existing whole-`Host` build remains the highest available point, and the config-merge model makes it the meaningful unit.
|
||||||
|
- Prior art: the toplevel-build check established by the laptop MVI, already wired as the flake's `checks` output.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- The Anthropic API key.
|
||||||
|
It is the natural next secret, but it is consumed as an environment variable rather than a file path, and conflating that shape with the bootstrap work would obscure both.
|
||||||
|
- The WireGuard/ProtonVPN key, which has no `Module` to consume it yet.
|
||||||
|
- Declarative wifi credentials.
|
||||||
|
NetworkManager profile secrets are fiddly and joining from the console currently works.
|
||||||
|
- Fleet-wide SSH host verification.
|
||||||
|
With one `Host` there is nothing to verify against, and the choice of whether to identify machines by name or address should be made when a second machine exists and the answer is known rather than guessed.
|
||||||
|
- Provisioning any identity for a `Host` that does not exist yet.
|
||||||
|
The procedure is documented; no key is generated for `zeus` or the servers.
|
||||||
|
- Rotating the LUKS passphrase or coupling it to secret decryption.
|
||||||
|
- Hardware-token identities.
|
||||||
|
A YubiKey can be added later as an additional admin recipient without changing any decision here.
|
||||||
|
- Any change to how the flake is fetched during an install.
|
||||||
|
|
||||||
|
## Further Notes
|
||||||
|
|
||||||
|
- **The lockout risk is confined to fresh installs.**
|
||||||
|
On `neogaia` the transition is safe: if activation fails, the rebuild fails and the running generation persists with the existing hand-set password intact.
|
||||||
|
A machine being installed for the first time has no such fallback, because the password now arrives only from a decrypted secret — which is exactly why its identity is provisioned before the first boot rather than after it.
|
||||||
|
- **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.
|
||||||
|
Losing the vault without a backup means losing the ability to add recipients or recover a wiped `Host`, even though every currently-running machine keeps working from its own identity.
|
||||||
|
- **Stable SSH host keys were nearly given up** in favour of deriving identities from them, and were recovered by inverting the dependency.
|
||||||
|
The rule that made it work generalizes: exactly one secret per machine must arrive out of band, and making that one thing a purpose-built key rather than a repurposed one keeps everything else declarable.
|
||||||
|
- **Adding a `Host` is a re-key, not a re-encrypt.**
|
||||||
|
A `sops` file holds a single data key encrypted once per recipient, so registering a new machine rewrites only that metadata, and the cost stays constant as the fleet grows to five.
|
||||||
|
- **This is what `ADR 0001` chose `sops-nix` for.**
|
||||||
|
The shared-plus-per-`Host` file split with overlapping recipients is precisely the multi-recipient, grouped-file model that decided against `agenix`; the topology change replaces how identities are obtained, not why the tool was picked.
|
||||||
37
.claude/tasks/0001-skeleton-and-building-host.md
Normal file
37
.claude/tasks/0001-skeleton-and-building-host.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
spec: laptop-mvi
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Stand up the `Skeleton` and a minimal `neogaia` `Host` that evaluates and whose system toplevel builds — the walking skeleton every later slice extends and re-verifies against.
|
||||||
|
|
||||||
|
The `Skeleton` is a hand-rolled flake (no flake-parts): `nixos-unstable` base channel, an `unstable overlay` exposing `nixpkgs-unstable` as `unstable.<name>`, a `stable overlay` exposing `nixos-26.05` as `stable.<name>`, and chaotic-nyx wired as an input with its overlay and binary cache.
|
||||||
|
The helper lib is trimmed to three pieces: the `Auto-loader` (recursively discovers and imports every `Module` under `modules/` and every `Host` under `hosts/` with no null-placeholder traversal hack), the host-builder, and the script-from-file helper.
|
||||||
|
`with lib.my` is not used — dependencies are `inherit`ed explicitly.
|
||||||
|
The `Enable convention` uses the stdlib enable-option helper; every `Module` is imported unconditionally and guards its body with `mkIf config.modules.<path>.enable`.
|
||||||
|
home-manager is sourced from `nix-community` (master, nixpkgs followed) and integrated as a NixOS module with global packages and user packages.
|
||||||
|
The `user` is an explicit option defaulting to `alexion` (no impure environment lookup), placed in `wheel`, driving the system user and the home-manager user in lockstep.
|
||||||
|
The `neogaia` `Host` carries only enough (placeholder `hardware-configuration.nix`, filesystems/bootloader stubs, `stateVersion`) to make `nixosConfigurations.neogaia.config.system.build.toplevel` evaluate and build; real disk/kernel/networking arrive in later slices.
|
||||||
|
|
||||||
|
Design the per-`Host` layout so disk layout, kernel, and channel are all per-`Host` concerns from the start (story 20), so the desktop and servers extend this foundation without restructuring.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `nix flake check` succeeds and the flake exposes `nixosConfigurations.neogaia`.
|
||||||
|
- [x] `nixosConfigurations.neogaia.config.system.build.toplevel` builds.
|
||||||
|
- [x] Adding a new `.nix` file under `modules/` is auto-discovered and imported without editing any `imports` list, and stays inert until its `enable` flag is set.
|
||||||
|
- [x] All three overlays resolve: `unstable.<pkg>`, `stable.<pkg>`, and a chaotic-nyx package are each reachable in a `Host`.
|
||||||
|
- [x] home-manager builds as part of the same `nixos-rebuild switch` toplevel (system + user environment atomic).
|
||||||
|
- [x] The `user` option defaults to `alexion`, has no impure environment lookup, places the user in `wheel`, and drives both the system and home-manager user.
|
||||||
|
- [x] The old `nixosModules` flake output and the `with lib.my` idiom are absent.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Verification.** `nix flake check` builds `checks.x86_64-linux.neogaia` = the Host toplevel (the spec's primary seam). Overlays confirmed via `nix eval` of `pkgs.unstable.hello` (2.12.3), `pkgs.stable.hello` (2.12.1), and `pkgs.linuxPackages_cachyos.kernel` (7.1.3, from chaotic). Auto-loader inertness confirmed both ways: the reference `modules/example.nix` is off by default, and `extendModules` with `modules.example.enable = true` activates its body.
|
||||||
|
- **chaotic binary cache.** Wired via `inputs.chaotic.nixosModules.default`, which puts both the `nyx-cache.chaotic.cx` substituter and its trusted public key into the built config (verified by evaluating `config.nix.settings.substituters`/`trusted-public-keys`). chaotic deliberately does **not** follow our nixpkgs, so the cache stays usable. Making the substituter/key explicit is task 0003's concern; here it is inherited from the module.
|
||||||
|
- **Shared base lives in `system/`.** The Skeleton's shared base config (overlays, `user`, flakes, home-manager wiring) is a `system/` module always imported by the host-builder, kept separate from the auto-loaded feature `Module`s under `modules/` so the base is never gated by an `enable` flag.
|
||||||
|
- **`modules/example.nix` kept intentionally.** It is the Auto-loader / Enable-convention reference every real Module copies; remove it once a real Module supersedes its teaching value.
|
||||||
|
- **`scriptFromFile` present but unused.** The task mandates the helper lib carry it ("the script-from-file helper"); its first caller lands with a later Module.
|
||||||
|
- **Home-manager base user only.** The base sets `home.username`/`homeDirectory`/`stateVersion` for the `user`; `extraSpecialArgs` passes both `inputs` and `my` (the flake lib) so upcoming HM Modules (fish/tmux/nvim) can reach `scriptFromFile`.
|
||||||
|
- **Deviations from plan.** Added an `options.user.description` (GECOS) alongside `user.name` — small and expected for a real account. Baseline `git` + global `allowUnfree` are set in the base (git is required for flakes; unfree is needed by chaotic/home-manager and later Claude Code). Placeholder `fileSystems`/bootloader and `hardware-configuration.nix` in `neogaia` are stubs that task 0002 (disko) replaces.
|
||||||
31
.claude/tasks/0002-neogaia-disk-and-boot.md
Normal file
31
.claude/tasks/0002-neogaia-disk-and-boot.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
spec: laptop-mvi
|
||||||
|
blocked-by: 0001-skeleton-and-building-host
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Declare the `neogaia` laptop's disk with `disko` and make it unlock and boot on real hardware: a LUKS-encrypted btrfs volume with subvolumes plus zram swap, on an EFI system partition using systemd-boot, with the LUKS passphrase prompted at boot via the initrd.
|
||||||
|
|
||||||
|
The layout must build from the same tree as the `Host` toplevel (so the whole-`Host` build exercises it), and must be expressed as a per-`Host` disk concern so other machines can declare their own layouts later.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `neogaia` declares a `disko` layout: LUKS-encrypted btrfs with subvolumes plus zram swap on an EFI system partition.
|
||||||
|
- [x] systemd-boot is the bootloader; the initrd prompts for the LUKS passphrase so a normal boot unlocks the encrypted disk.
|
||||||
|
- [x] The `disko` layout builds as part of the `neogaia` toplevel build (no separate invocation needed to catch layout errors).
|
||||||
|
- [x] The disk layout is a per-`Host` concern, expressible differently for future `Host`s without restructuring the `Skeleton`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Layout.** One GPT disk at `/dev/nvme0n1`: a 512M EF00 ESP (vfat, `umask=0077`) mounted at `/boot`, and a 100%-fill LUKS partition (`cryptroot`, `allowDiscards`) holding a btrfs filesystem with three subvolumes — `@root` → `/`, `@home` → `/home`, `@nix` → `/nix` — each mounted `compress=zstd,noatime`.
|
||||||
|
There is deliberately no on-disk swap partition; swap is RAM-backed zram.
|
||||||
|
- **Skeleton vs. per-Host split.** The disko *module* (`inputs.disko.nixosModules.disko`) is wired into the host-builder in `lib/default.nix`, so every `Host` can interpret a `disko.devices` declaration; the *layout itself* lives in `hosts/neogaia/disk.nix`.
|
||||||
|
A future `Host` declares a different layout, or none at all (an undeclared `disko.devices` is a no-op), so servers that preserve an existing pool by import need no `Skeleton` change.
|
||||||
|
- **disko input follows nixpkgs.** Unlike chaotic (which must not), disko follows our `nixpkgs` so it builds against the same base.
|
||||||
|
- **Boot unlock.** disko's `type = "luks"` (no key file) generates `boot.initrd.luks.devices.cryptroot`, so the classic initrd prompts for the passphrase on a normal boot; the `nvme` initrd module was already present in `hardware-configuration.nix`.
|
||||||
|
- **zram enabled directly, not yet a Module.** Criterion 1 requires "plus zram swap," so `zramSwap.enable = true` is set on the `Host` now.
|
||||||
|
Task 0003 owns the reusable zram toggle `Module` and will lift this line into it; the placeholder `fileSystems`/bootloader stubs from task 0001 are removed here since disko now derives `fileSystems`.
|
||||||
|
- **Verification.** `nix flake check` (the `checks.x86_64-linux.neogaia` toplevel) builds green.
|
||||||
|
Confirmed via `nix eval`: disko-derived `fileSystems` = `/`,`/home`,`/nix` on btrfs `/dev/mapper/cryptroot` + `/boot` on the ESP; `boot.initrd.luks.devices` = `["cryptroot"]`; `systemd-boot.enable` and `zramSwap.enable` both true; `swapDevices` empty.
|
||||||
|
The genuine end-to-end confirmation is the manual `disko-install` reimage, which is irreversible by nature and not automated.
|
||||||
26
.claude/tasks/0003-kernel-and-hardware.md
Normal file
26
.claude/tasks/0003-kernel-and-hardware.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
spec: laptop-mvi
|
||||||
|
blocked-by: 0001-skeleton-and-building-host
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Give `neogaia` the kernel and hardware enablement it needs to run well on the Dell XPS 13 9380: the CachyOS kernel pulled as a binary from chaotic-nyx (not compiled from source), Intel microcode, and the redistributable firmware for the QCA6174 wifi. Add a zram toggle `Module` and enable it here.
|
||||||
|
|
||||||
|
The kernel is selected through a small per-`Host` kernel mechanism so other `Host`s can choose different kernels. The chaotic substituter and its trusted public key are added to the Nix settings so the kernel is fetched from the binary cache from the first build.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `neogaia` runs the CachyOS kernel selected via a per-`Host` kernel mechanism, sourced from chaotic-nyx.
|
||||||
|
- [x] The chaotic substituter and trusted public key are in the Nix settings, so the kernel is fetched from cache rather than compiled.
|
||||||
|
- [x] Intel microcode is enabled.
|
||||||
|
- [x] Redistributable firmware is enabled so the QCA6174 wifi hardware is available.
|
||||||
|
- [-] A zram toggle `Module` exists (following the `Enable convention`) and is enabled on `neogaia`. — Module dropped in PR review; zram is enabled inline on `neogaia` instead (see notes).
|
||||||
|
- [x] The `neogaia` toplevel still builds with all of the above.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Per-`Host` kernel mechanism = native `boot.kernelPackages`.** neogaia sets `boot.kernelPackages = pkgs.linuxPackages_cachyos` directly in its Host directory (`hosts/neogaia/default.nix`). No custom wrapper option was added: `boot.kernelPackages` is already a per-`Host` setting, so other `Host`s pick their own kernel the same way. A string→package wrapper would have been premature abstraction with one `Host` and one kernel, so it was deliberately left out; the "mechanism" is the per-`Host` placement of the native option.
|
||||||
|
- **Substituter/key live in the shared base, via the `extra-` options.** They were added to `system/default.nix` (shared by every `Host`), not just neogaia, because the chaotic module is wired for all `Host`s and the cache is general plumbing. `nix.settings.extra-substituters` / `extra-trusted-public-keys` are used rather than the replacing `substituters` / `trusted-public-keys`, so `cache.nixos.org` (and any other substituter) is only appended to, never dropped. chaotic's own module also provides these entries; the explicit declaration is belt-and-suspenders and keeps the built system's cache config visible and independent of that module.
|
||||||
|
- **Dev-host build needed a daemon-level cache.** Building the toplevel here first compiled the CachyOS kernel (and rustc bootstrap) from source, because the build daemon's `/etc/nix/nix.conf` had no `nyx-cache` substituter — the built system's `nix.settings` do not govern the daemon doing the build, and the dev user is a non-trusted client that cannot add substituters from the CLI. Adding `extra-substituters`/`extra-trusted-public-keys` for `nyx-cache` to `/etc/nix/nix.conf` (sudo) and restarting `nix-daemon` fixed it; the build then fetched the kernel (7.1.3) from the cache. Recorded as a gotcha in `CLAUDE.md`.
|
||||||
|
- **zram is enabled inline, not as a `Module` (criterion 5 dropped).** The task asked for a zram toggle `Module`, and one was built first (`modules/zram.nix`), but PR review rejected it as a single-line abstraction that wraps the native `zramSwap.enable` toggle without adding anything. It was removed, and `neogaia` sets `zramSwap.enable = true` directly, as it did before task 0003. The `Enable convention` reference remains `modules/example.nix`; real feature `Module`s arrive with fish/tmux/nvim/Claude Code in later tasks.
|
||||||
28
.claude/tasks/0004-networking-and-base-system.md
Normal file
28
.claude/tasks/0004-networking-and-base-system.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
spec: laptop-mvi
|
||||||
|
blocked-by: 0001-skeleton-and-building-host
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Make the booted laptop a usable console I can log into and reach remotely: NetworkManager for joining wifi, an SSH daemon for driving the rest of the setup over the network, and the base locale settings.
|
||||||
|
|
||||||
|
Set timezone `America/New_York`, locale `en_GB.UTF-8`, and console keymap `us`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] NetworkManager is enabled so wifi can be joined from the console.
|
||||||
|
- [x] An SSH daemon is enabled so the machine can be driven remotely.
|
||||||
|
- [x] Timezone is `America/New_York`, locale is `en_GB.UTF-8`, console keymap is `us`.
|
||||||
|
- [x] The `neogaia` toplevel still builds with all of the above.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Placement in the Host, not a Module.** NetworkManager, the SSH daemon, and the locale/timezone/keymap settings all live directly in `hosts/neogaia/default.nix`, alongside the kernel/hardware/zram lines from task 0003.
|
||||||
|
This follows the precedent set in that task, where a speculative enable-gated Module was dropped in review in favour of inlining for the single-Host MVI.
|
||||||
|
A shared locale Module or a networking Module can be extracted later when a second Host actually needs the same settings; extracting now would be speculative generality.
|
||||||
|
- **SSH left unhardened deliberately.** `services.openssh.enable = true` keeps NixOS's default password authentication on.
|
||||||
|
This is required by the install flow: first-boot access is over SSH with the hand-set bootstrap password, and no SSH keys or sops-derived age key exist until the install generates the Host's SSH host key.
|
||||||
|
Moving to key-only auth / `hashedPasswordFile` is the first post-boot follow-up per the spec's Secrets section, out of scope for the MVI.
|
||||||
|
- **Locale/timezone mix is as specified.** `i18n.defaultLocale = "en_GB.UTF-8"` with `time.timeZone = "America/New_York"` and `console.keyMap = "us"` mixes region and locale; this matches the operator's stated preferences verbatim and is intentional.
|
||||||
|
- **Verification.** Built the primary seam — `nix build .#checks.x86_64-linux.neogaia` (the Host toplevel) — to exit 0; the systemd units for `wpa_supplicant` (NetworkManager's backend) and openssh appear in the build. The five option values were also confirmed via `nix eval`.
|
||||||
44
.claude/tasks/0005-fish-shell-module.md
Normal file
44
.claude/tasks/0005-fish-shell-module.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
spec: laptop-mvi
|
||||||
|
blocked-by: 0001-skeleton-and-building-host
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
A fish `Module`, configured natively via home-manager, that makes the shell feel like the current CachyOS setup but is correct for NixOS. Translate the existing `cachyos-config.fish` and the rest of the fish snapshot under `reference/home/.config/fish/`: the greeting, the bat-manpager, the `done` and bang-bang plugins, the helper functions, and the eza/nav aliases. Drop or replace every Arch/pacman-specific part with its NixOS equivalent. Set fish as the default login shell.
|
||||||
|
|
||||||
|
Configure the plugins natively through home-manager rather than a fish plugin manager.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A fish `Module` (following the `Enable convention`) is enabled on `neogaia` and configured natively via home-manager.
|
||||||
|
- [x] The greeting, bat-manpager, `done` and bang-bang plugins, helper functions, and eza/nav aliases from the reference config are reproduced.
|
||||||
|
- [x] All Arch/pacman-specific parts are dropped or replaced with NixOS equivalents.
|
||||||
|
- [x] fish is the user's default login shell.
|
||||||
|
- [x] The `neogaia` toplevel still builds with the fish `Module` enabled.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Plugins are native, not inlined.** `done` and `bang-bang` come from `pkgs.fishPlugins.*` via `programs.fish.plugins`; home-manager drops them into `~/.config/fish/conf.d/` where fish auto-sources them.
|
||||||
|
The reference config inlined the bang-bang `__history_previous_command` functions and binds by hand; the plugin supplies those, so they are not re-inlined.
|
||||||
|
- **`done` tuning uses `set -g`, not `set -U`.** The reference set `__done_min_cmd_duration`/`__done_notification_urgency_level` as universal variables, which persist to the universal-variable file and then ignore config changes.
|
||||||
|
A declarative config must own these each session, so they are set global (`set -g`) in `interactiveShellInit`.
|
||||||
|
- **Arch/pacman aliases dropped:** `grubup`, `fixpacman`, `mirror` (`cachyos-rate-mirrors`), `apt`/`apt-get` (`man pacman`), `big` (`expac`), `gitpkg`, `rip` (`expac`).
|
||||||
|
**Replaced with NixOS equivalents:** `update` → `sudo nixos-rebuild switch` (was `pacman -Syu`), `cleanup` → `sudo nix-collect-garbage -d` (was `pacman -Rns`).
|
||||||
|
- **Machine-specific PATH hacks dropped, per spec story 6 ("no impure environment lookup").** The hardcoded `BUN_INSTALL`, the `node-v24...` tarball path, and `~/Applications/depot_tools` from `config.fish` are absolute/impure and are not reproduced; such tooling should be Nix-provided when its own Module arrives.
|
||||||
|
The portable bits are kept: `~/.local/bin` on PATH (guarded) and sourcing `~/.fish_profile`.
|
||||||
|
- **`env.fish` and `rustup.fish` deliberately not carried over.** `ANDROID_HOME`/platform-tools (Android SDK) and `source ~/.cargo/env.fish` (rust) are dev-toolchain integrations outside the MVI's core tooling (fish/tmux/nvim/Claude Code); they belong to future per-toolchain Modules that provide those tools through Nix rather than sourcing an impure env file.
|
||||||
|
- **`hw` (`hwinfo --short`) and `tb` (`nc termbin.com 9999`) dropped.** These are generic rather than pacman-specific, but each needs an extra package (`hwinfo`, a `netcat`) that the minimal install does not otherwise pull in; left out of the MVI and easy to add later.
|
||||||
|
- **`copy` kept verbatim** (including the upstream `trim-right` call) to preserve exact parity with the current shell.
|
||||||
|
- **Verification.** The `neogaia` system toplevel builds (the spec's primary seam).
|
||||||
|
The rendered `~/.config/fish/` was inspected in the build output: aliases, the three helper functions, the `fastfetch` greeting, the bat manpager, the `done` tuning vars, and the `conf.d/plugin-done.fish` + `conf.d/plugin-bang-bang.fish` plugin files are all present; `users.users.alexion.shell` resolves to `pkgs.fish`.
|
||||||
|
|
||||||
|
### Post-review adjustments
|
||||||
|
|
||||||
|
- **`defaultShell` option.** Setting fish as the login shell moved behind `modules.fish.defaultShell` (default `false`, gated with `mkIf`); `neogaia` opts in explicitly. Enabling the Module alone no longer changes the login shell.
|
||||||
|
- **Abbreviation-first.** Every non-eza alias is now a `shellAbbr` (the eza `ls` family stays an alias), `preferAbbrs = true`, and `generateCompletions = true` is pinned rather than left to the upstream default.
|
||||||
|
- **vi command-line editing.** `interactiveShellInit` sets `fish_key_bindings fish_vi_key_bindings`; the `bang-bang` plugin re-binds `!`/`$` in insert mode via its own `--on-variable fish_key_bindings` handler, so the switch keeps them working.
|
||||||
|
- **Trimmed aliases.** Navigation capped at four dots (`.....`/`......` dropped); `psmem`, `psmem10`, `dir`, `vdir`, and `please` removed.
|
||||||
|
- **Interactive init lives in a real fish file.** The Module lives at `modules/fish/fish.nix`; its `interactiveShellInit` is `builtins.readFile ./config.fish`, so the interactive init is written as one editable fish file (vi editing, `EDITOR`/`VISUAL`, the bat manpager, the done plugin tuning, and `~/.local/bin`/`~/.fish_profile`) that home-manager renders into `~/.config/fish/config.fish`. The file is small enough that splitting it into fragments was not worth the indirection; Nix inlines it at build time, so nothing of ours is autoloaded from a separate runtime file.
|
||||||
|
- **`copy` stays a function file.** `functions/copy.fish` holds the non-trivial `copy` body, read into the `functions` option; fish autoloads function files lazily, so that is the idiomatic home for a function. Trivial one-liner functions stay inline in `fish.nix`.
|
||||||
|
- The Auto-loader only collects `.nix`, so every `.fish` file under `modules/fish/` is inert to it.
|
||||||
43
.claude/tasks/0006-tmux-module.md
Normal file
43
.claude/tasks/0006-tmux-module.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
spec: laptop-mvi
|
||||||
|
blocked-by: 0001-skeleton-and-building-host
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
A tmux `Module`, configured natively via home-manager, that reproduces the current terminal multiplexer exactly: the existing `tmux.conf` text (under `reference/home/.config/tmux/`) inlined verbatim, with no plugin manager needed.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A tmux `Module` (following the `Enable convention`) is enabled on `neogaia` and configured natively via home-manager.
|
||||||
|
- [x] The existing `tmux.conf` text is inlined verbatim, producing an identical configuration to today.
|
||||||
|
- [x] No tmux plugin manager is used.
|
||||||
|
- [x] The `neogaia` toplevel still builds with the tmux `Module` enabled.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
**Approach — option translation ("the nix way") instead of byte-verbatim inlining.**
|
||||||
|
On the operator's explicit call ("I would prefer to do things the nix way. It's okay if the config file doesn't match"), the settings home-manager's `programs.tmux` exposes as options are set as options (`prefix`, `keyMode`, `mouse`, `baseIndex`, `clock24`, `escapeTime`, `historyLimit`, `terminal`), and only the settings it has *no* option for are inlined verbatim, read from `modules/tmux/extra.conf` via `builtins.readFile`.
|
||||||
|
The generated `~/.config/tmux/tmux.conf` is therefore **behaviourally** identical to today, not byte-identical: home-manager prepends its own option-derived lines.
|
||||||
|
This was preferred over `xdg.configFile.source = ./tmux.conf` (which would have been byte-identical) after weighing both.
|
||||||
|
Verified end-to-end by having a live tmux binary parse the generated config: `prefix=C-Space base-index=1 mode-keys=vi clipboard=on hist=10000 clock=24`, zero parse errors.
|
||||||
|
|
||||||
|
**`clock24 = true` is required, not cosmetic.**
|
||||||
|
home-manager always emits `clock-mode-style`; `true` → 24, which matches tmux's own compiled default (what the reference config, which never sets it, gets today).
|
||||||
|
Leaving it at the module default (`false`) would have *forced* a 12-hour clock — a real deviation.
|
||||||
|
|
||||||
|
**Pane navigation stays in `extra.conf`.**
|
||||||
|
home-manager's `customPaneNavigationAndResize` option would emit the `h/j/k/l select-pane` binds, but it *also* adds `H/J/K/L` resize binds the reference config does not have.
|
||||||
|
To stay faithful, the `h/j/k/l` binds are inlined in `extra.conf` and the option is left off.
|
||||||
|
|
||||||
|
**`secureSocket` left at the home-manager default (`true`).**
|
||||||
|
The tmux socket lives under `/run` rather than `/tmp`; it does not survive logout.
|
||||||
|
This differs from stock tmux behaviour and was accepted deliberately.
|
||||||
|
|
||||||
|
**Comments in `extra.conf` rewritten to the project convention.**
|
||||||
|
The reference `tmux.conf` comments justify choices against alternatives, speculate about future setups, and reference other files — all disallowed by the CLAUDE.md comment convention.
|
||||||
|
Since `extra.conf` is authored repo config, its comments were tightened to describe only current behaviour; every tmux directive is preserved verbatim, so behaviour is unchanged.
|
||||||
|
|
||||||
|
**Version-sensitivity (not a defect today).**
|
||||||
|
`programs.tmux.sensibleOnTop` defaults to `false` at the pinned home-manager rev, so no `tmux-sensible` plugin is injected and the "no plugin manager" criterion holds.
|
||||||
|
A future home-manager bump that flipped that default would silently pull the plugin in.
|
||||||
24
.claude/tasks/0007-nvim-module.md
Normal file
24
.claude/tasks/0007-nvim-module.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
spec: laptop-mvi
|
||||||
|
blocked-by: 0001-skeleton-and-building-host
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
An nvim `Module` that gives the primary user Neovim configured declaratively through **nixvim**, with **functional parity** to the operator's existing config.
|
||||||
|
Parity is about the "what" — the same plugins, keymaps, options, colorscheme, and behaviour — not the "how".
|
||||||
|
The mechanism is deliberately free to follow NixOS's declarative paradigm rather than transplanting the imperative lazy.nvim setup: plugins are managed by Nix (no plugin manager, no runtime cloning, no lockfile), and as much of the config as possible is expressed as typed Nix, with raw Lua kept only as an escape hatch.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] An nvim `Module` (following the `Enable convention`) is enabled on `neogaia`.
|
||||||
|
- [x] Neovim is configured via **nixvim**, wired as a flake input (`nixvim.inputs.nixpkgs.follows = "nixpkgs"`), consumed as its home-manager module under `home-manager.users.<user>.programs.nixvim`.
|
||||||
|
- [x] Functional parity with the previous config: the same plugins (neogit, diffview, gitsigns, oil, snacks, gbprod-nord, render-markdown, which-key, treesitter), keymaps, `vim` options, the `nord` colorscheme, the Neogit blame-toggle autocmd, and markdown concealment — verified headless against the generated config.
|
||||||
|
- [x] Runtime dependencies `git`, `ripgrep`, and `fd` are provided by Nix; `gcc` is not needed because Nix builds the treesitter grammars.
|
||||||
|
- [x] The `neogaia` toplevel still builds with the nvim `Module` enabled.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **nixvim, typed Nix first.** `modules/nvim/nvim.nix` enables `programs.nixvim` with `opts`, `globals`, `keymaps`, and typed `plugins.*` settings. Treesitter uses `plugins.treesitter` (`highlight.enable`, `indent.enable`, `grammarPackages` from the module's own `builtGrammars`) covering nix, lua, bash, fish, markdown, rust, python, java, kotlin, c, cpp, html, css, javascript, typescript, go.
|
||||||
|
- **The imperative remainder** — the `gbprod/nord.nvim` setup + colorscheme call, the markdown `conceallevel` autocmd, and the Neogit blame-toggle `BufUnload` autocmd — lives in `modules/nvim/config.lua`, pulled in via `extraConfigLua = builtins.readFile ./config.lua`. `gbprod-nord` comes in through `extraPlugins` because nixvim's `colorschemes.nord` is a different plugin.
|
||||||
|
- **Verification.** `nix build .#…programs.nixvim.build.package` exits 0 and the whole toplevel evaluates. The generated config was exercised headless (launched with `-u` the generated init and a scratch `HOME`, since the wrapper otherwise loads the dev host's real `~/.config/nvim`): all options, keymaps, plugins, the `nord` colorscheme, treesitter highlight + indent, and markdown conceal load with no errors.
|
||||||
23
.claude/tasks/0008-claude-code-module.md
Normal file
23
.claude/tasks/0008-claude-code-module.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
spec: laptop-mvi
|
||||||
|
blocked-by: 0001-skeleton-and-building-host
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Install Claude Code declaratively on `neogaia`, and make it authenticatable without a browser on the laptop so it can be used over the console/SSH via the paste-code flow or an API key.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Claude Code is installed declaratively (following the `Enable convention` if expressed as a `Module`) and enabled on `neogaia`.
|
||||||
|
- [x] The browserless authentication path (paste-code flow or API key) is documented so it works over console/SSH.
|
||||||
|
- [x] The `neogaia` toplevel still builds with Claude Code included.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Native home-manager module, not a raw package.** Claude Code is enabled through home-manager's own `programs.claude-code` module (`home-manager.users.<user>.programs.claude-code.enable = true`), mirroring how `tmux`/`fish` use their native home-manager options rather than dropping a package into `home.packages`. The module ships within home-manager itself, so — unlike `nvim`/nixvim — no new flake input is needed. Per the invocation's steer to prefer the tmux/nvim conventions over the task wording, the feature `Module` at `modules/claude-code/claude-code.nix` is kept as thin as the `tmux` module: just the `enable` option and the delegation.
|
||||||
|
- **No settings written.** The module manages no `~/.claude` contents and writes no `settings.json`, so login and first-run configuration stay interactive. This keeps auth material (subscription token or API key) out of the repo.
|
||||||
|
- **Auth docs co-located with the module.** The browserless authentication guide lives at `modules/claude-code/authentication.md`, next to the module, following the repo pattern where each module directory holds its own supporting files. It covers both the paste-code OAuth flow (open the printed URL on another device, paste the code back — works unchanged over SSH) and the `ANTHROPIC_API_KEY` path. This is distinct from task 0009's OS-install docs, which cover `disko-install`, not the CLI login.
|
||||||
|
- **Verification.** `nix build .#checks.x86_64-linux.neogaia` (the primary Host seam) builds the toplevel with `claude-code-2.1.209` included; `config.modules.claude-code.enable` and the home-manager `programs.claude-code.enable` both evaluate `true`.
|
||||||
|
- **Note on flake evaluation.** The new module file had to be `git add`ed before the flake could see it — flakes evaluate the git tree, so an untracked Module is invisible to the Auto-loader and the host errors with "option does not exist".
|
||||||
|
- **Personal config ported into the Module (beyond the acceptance criteria).** At the operator's request the declarative half of `~/.claude` now lives in the Module and is applied when it is enabled: the global agent instructions (`context = ./CLAUDE.md`), the skills tree (`skills = ./skills`, 15 skills), the attention-bell hook (`hooks."attention-bell.sh"`), and `settings.json` (`model = "opus"` plus the Stop/Notification/SessionStart hook wiring). Runtime state (`projects/`, `plugins/`, `cache/`, `history.jsonl`, sessions) and the `~/.claude/.credentials.json` secret are deliberately left out, so login survives rebuilds and no secret enters the repo. Stale `agents`/`commands` symlinks (into an outdated `~/wrk/claude`) were skipped. Verified against the built `home-files`: `~/.claude/{CLAUDE.md,settings.json,skills/,hooks/attention-bell.sh}` are all generated, with the hook executable. The `gitea-axi` SessionStart hook depends on that binary being on `PATH`; the flake does not yet provide it, so the hook is a no-op on a host until it is installed.
|
||||||
36
.claude/tasks/0009-install-flow-docs.md
Normal file
36
.claude/tasks/0009-install-flow-docs.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
spec: laptop-mvi
|
||||||
|
blocked-by: [0002-neogaia-disk-and-boot, 0003-kernel-and-hardware, 0004-networking-and-base-system, 0005-fish-shell-module, 0006-tmux-module, 0007-nvim-module, 0008-claude-code-module]
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Document the one-shot install procedure that turns the completed `neogaia` `Host` into a running encrypted laptop from the NixOS live ISO — the capstone, written once every functional slice is in place so it describes the actually-complete `Host`.
|
||||||
|
|
||||||
|
The procedure: push the repo to Gitea first; from the live ISO, join wifi, clone the repo locally (avoiding self-signed-TLS/auth problems with flake fetching during install), and run `disko-install` against the `neogaia` `Host` with the chaotic substituter passed to the install-time Nix daemon (or it compiles the CachyOS kernel from source on the USB stick). Then set the bootstrap password by hand via `nixos-enter` — never committed to the public repo — and reboot.
|
||||||
|
|
||||||
|
Note the bootstrap ordering (the flake must exist on Gitea before the install can consume it) and that moving the password to a `hashedPasswordFile` backed by a sops secret is the first post-boot task, out of scope here (per ADR 0001, an age key does not exist until the first install generates the SSH host key).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] The install procedure is documented end to end: push to Gitea → join wifi on the live ISO → clone locally → `disko-install` against `neogaia` → set bootstrap password via `nixos-enter` → reboot.
|
||||||
|
- [x] The docs state that the install-time Nix daemon must have the chaotic substituter configured, or the kernel compiles from source on the USB stick.
|
||||||
|
- [x] The docs explain that the local clone avoids self-signed-TLS/auth problems with flake fetching during install.
|
||||||
|
- [x] The bootstrap password is set by hand and never committed; the docs flag the sops-backed `hashedPasswordFile` migration as the first post-boot follow-up.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
The runbook lives at `docs/install.md`.
|
||||||
|
|
||||||
|
Every documented command was checked against the actual pinned tooling rather than written from memory:
|
||||||
|
|
||||||
|
- The `disko-install` and `disko` flags (`--flake`, `--disk NAME DEVICE`, `--write-efi-boot-entries`, `--option`, `--mode mount`) were read out of the pinned disko revision's wrapped scripts (the disko rev in `flake.lock`).
|
||||||
|
- A consequence surfaced there and shaped the doc: `disko-install` traps `EXIT` and **unmounts** the target, so the "set the bootstrap password" step must first remount with `disko --mode mount` before `nixos-enter`.
|
||||||
|
A naive `nixos-enter --root /mnt` straight after the install would have found nothing mounted.
|
||||||
|
- The chaotic substituter URL and trusted key are quoted verbatim from `system/default.nix`, and `--disk main /dev/nvme0n1` matches `hosts/neogaia/disk.nix`.
|
||||||
|
|
||||||
|
Two secrets are set by hand at install time, not one: the doc distinguishes the **LUKS passphrase** (prompted by disko at format, typed at every boot) from the **bootstrap login password** (set via `nixos-enter passwd`).
|
||||||
|
The task named only the login password; the LUKS passphrase is an unavoidable part of the same by-hand flow, so it is documented alongside for a complete runbook.
|
||||||
|
|
||||||
|
Beyond the task's terse list, the doc adds: a minimal-vs-graphical ISO split for joining wifi, and — from review — an SSH-key caveat for the clone plus an HTTPS-with-`sslVerify=false` fallback (which also reinforces the "git can skip verification where the flake fetcher can't" point behind the local-clone requirement).
|
||||||
|
No criteria were dropped.
|
||||||
62
.claude/tasks/0010-sops-skeleton-and-password.md
Normal file
62
.claude/tasks/0010-sops-skeleton-and-password.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
spec: sops-secrets
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
The tracer bullet for encrypted secrets: a working two-tier age identity model, with the primary user's login password arriving as a decrypted secret rather than a value typed into a running machine.
|
||||||
|
|
||||||
|
Establish the two identities the model rests on.
|
||||||
|
An admin identity is generated and stored as a secure note in the operator's password manager; only its public recipient ever appears in the repo, and no copy of the private half is committed in any form.
|
||||||
|
A host identity is generated on `neogaia` itself, onto its encrypted root, never transmitted, and deliberately not derived from the machine's SSH host key.
|
||||||
|
|
||||||
|
Commit a sops configuration naming both recipients and a shared secrets file encrypted to admin plus `neogaia`, holding the primary user's password hash.
|
||||||
|
The hash lives in the shared file rather than a per-host one because the same password is used on every machine, so per-host copies would only make rotation a multi-file edit.
|
||||||
|
|
||||||
|
Wire the tooling into the flake as unconditional plumbing in the shared base config — not behind an enable flag, on the same grounds as the overlays and the flakes settings.
|
||||||
|
The base config carries only the machinery: the flake input, the identity file location, and the default secrets file.
|
||||||
|
The password secret itself is declared beside the user declaration it feeds, so a reader finds the secret where they find its use.
|
||||||
|
|
||||||
|
The password secret must be marked as needed for user creation, which decrypts it in an earlier activation stage than ordinary secrets.
|
||||||
|
That ordering is why the host identity has to sit on the root filesystem rather than anywhere mounted later.
|
||||||
|
|
||||||
|
The transition is safe on `neogaia`: if activation fails the rebuild fails and the running generation persists with its existing hand-set password intact.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] An admin age identity exists in the operator's password manager; its private half is committed nowhere, in no form
|
||||||
|
- [x] A host age identity exists on `neogaia`'s encrypted root and was generated on the machine
|
||||||
|
- [x] The sops configuration in the repo names the admin recipient and the `neogaia` recipient
|
||||||
|
- [x] A shared secrets file, encrypted to admin plus `neogaia`, holds the primary user's password hash
|
||||||
|
- [x] The secrets flake input is added, following the base nixpkgs
|
||||||
|
- [x] The shared base config carries the machinery unconditionally — identity file location and default secrets file — with no enable flag
|
||||||
|
- [x] The password secret is declared beside the user declaration, consumed through `hashedPasswordFile`, and marked as needed for user creation
|
||||||
|
- [x] `nix flake check` builds the `neogaia` toplevel; a mistyped secret name or missing secrets file fails it
|
||||||
|
- [x] Manual confirmation: `neogaia` activates, and console login succeeds against the decrypted password hash
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
**`users.mutableUsers = false` was required and is not in the plan.**
|
||||||
|
NixOS applies a declared password hash to an account that already exists in `/etc/shadow` only when `mutableUsers` is false — `update-users-groups.pl` guards both assignments on it.
|
||||||
|
At the default of true, `alexion` already existed, so `hashedPasswordFile` would have been ignored and the hand-set password kept, silently.
|
||||||
|
The final acceptance criterion would then have passed while proving nothing, because the login being tested would still have been the old one.
|
||||||
|
|
||||||
|
Two consequences follow, neither sanctioned by the spec.
|
||||||
|
`passwd` no longer works, so rotating the password means re-running `mkpasswd`, re-encrypting the shared file, and rebuilding.
|
||||||
|
Root has no declared password and is therefore locked (`!`), which blocks direct root login and the systemd emergency shell's `sulogin` prompt.
|
||||||
|
`sudo` from the wheel group is unaffected, and generation rollback or `init=/bin/sh` remains available for recovery.
|
||||||
|
Leaving root locked was chosen over declaring a root password, on the grounds that the recovery paths that survive a locked root do not depend on `/etc/shadow` at all.
|
||||||
|
This is worth folding back into the parent spec before the servers exist, where a locked root and no SSH key would be a harder corner.
|
||||||
|
|
||||||
|
**The negative half of the build criterion was exercised, not assumed.**
|
||||||
|
A mistyped secret name fails with `the key 'alexion-passwrd' cannot be found`.
|
||||||
|
A missing secrets file fails with `Path 'secrets/absent.yaml' does not exist in Git repository`.
|
||||||
|
Both were tested by temporary edits that were reverted.
|
||||||
|
|
||||||
|
**Identity handling.**
|
||||||
|
The admin identity was generated by the operator in a terminal outside this session, so no copy of its private half ever reached the agent or the repo.
|
||||||
|
The host identity was generated on `neogaia` into `/var/lib/sops-nix/key.txt` (mode 0400, root) on the `@root` subvolume of the LUKS-encrypted `cryptroot`, and never transmitted.
|
||||||
|
|
||||||
|
**Follow-up worth flagging for 0011.**
|
||||||
|
`services.openssh.enable` is true on `neogaia` with no declared `authorizedKeys`, so SSH is not a fallback route in if a future decryption failure locks the console.
|
||||||
|
The task that makes the SSH host keys secrets is the natural place to settle that.
|
||||||
60
.claude/tasks/0011-neogaia-ssh-host-keys.md
Normal file
60
.claude/tasks/0011-neogaia-ssh-host-keys.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
spec: sops-secrets
|
||||||
|
blocked-by: 0010-sops-skeleton-and-password
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
`neogaia`'s SSH host keys become secrets, so reimaging the laptop no longer invalidates its host identity or breaks `known_hosts` for every client that has ever connected to it.
|
||||||
|
|
||||||
|
Introduce a per-host secrets file for `neogaia`, encrypted to the admin identity plus `neogaia` alone — the first file in the repo that is not readable by the whole fleet, and the thing that keeps a compromised machine from decrypting another's material.
|
||||||
|
The host's SSH **private** keys go in it.
|
||||||
|
|
||||||
|
The host **public** keys are committed in plaintext.
|
||||||
|
Publishing them is their function, and encrypting them would impose a re-key cycle every time one changes.
|
||||||
|
|
||||||
|
Stop the SSH daemon generating its own host keys and point it at the decrypted paths instead.
|
||||||
|
These secrets decrypt in the ordinary activation stage rather than the early pre-user one, so this slice exercises the second of the two decryption paths.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A secrets file for `neogaia` exists, encrypted to the admin identity and `neogaia` only — not to any other recipient
|
||||||
|
- [x] `neogaia`'s SSH host private keys are stored in it
|
||||||
|
- [x] The corresponding host public keys are committed in plaintext
|
||||||
|
- [x] The SSH daemon no longer generates its own host keys and reads the decrypted paths
|
||||||
|
- [x] The host key secrets are declared beside the SSH daemon configuration that consumes them
|
||||||
|
- [x] `nix flake check` builds the `neogaia` toplevel
|
||||||
|
- [x] Manual confirmation: after activation the secrets materialize with the declared ownership and mode, the daemon adopts the restored keys, and the host fingerprint presented to a client is unchanged
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
**Both key types were preserved, not just ed25519.**
|
||||||
|
The running daemon served an ed25519 and an RSA host key, and a client that pinned either would break if only one were carried over.
|
||||||
|
Both private halves are in `secrets/neogaia.yaml`.
|
||||||
|
|
||||||
|
**The decrypted keys stay at their default `/run/secrets/` paths.**
|
||||||
|
The first attempt set each secret's `path` to the conventional `/etc/ssh/ssh_host_*_key`, which has sops plant a symlink inside a directory NixOS otherwise manages through `setup-etc`.
|
||||||
|
It worked, but it buys nothing: `sshd` reads whatever `HostKey` names, and the extra `/etc` interaction depends on activation ordering that nothing in the config pins.
|
||||||
|
The `HostKey` lines now interpolate `config.sops.secrets.<name>.path`, so the daemon and the secret cannot disagree about where the key is.
|
||||||
|
`/etc/ssh` ends up holding no key material at all.
|
||||||
|
|
||||||
|
**`restartUnits = [ "sshd.service" ]` is not in the plan and is needed.**
|
||||||
|
`sshd` reads its host keys once at startup.
|
||||||
|
Without this, re-keying the host would rewrite the decrypted files while the daemon kept serving the old keys from memory until some unrelated restart — silently, and precisely the identity drift this task exists to prevent.
|
||||||
|
The plan's manual criterion would not have caught it, since it was verified on a switch where the keys had not changed.
|
||||||
|
|
||||||
|
**The committed public keys have no consumer yet.**
|
||||||
|
An intermediate version deployed them to `/etc/ssh` via `environment.etc`.
|
||||||
|
That was dropped as scope the task did not ask for: `sshd` derives the public half from the private key at load, so nothing read them.
|
||||||
|
They are committed, per the criterion, and the task that distributes `known_hosts` to clients is where they acquire a use.
|
||||||
|
|
||||||
|
**Verification was stronger than a before/after comparison.**
|
||||||
|
After activation the leftover `/etc/ssh/ssh_host_*_key` symlinks from the first attempt were removed and `sshd` restarted with no key material anywhere in `/etc/ssh`.
|
||||||
|
It came back active and presented `SHA256:2ysuBX0+Z6GbdCTujz5JHX6rqnJzIyWhYNrxdhhGwEM` (ed25519) and `SHA256:y6Tl3P/FvfufblfG059BfCsSkMYX8Zk2EpFQvWzCAew` (RSA) — identical to the pre-change fingerprints.
|
||||||
|
The generated `sshd-keygen.service` has no `ExecStart` at all, which is what confirms generation is off rather than merely idle.
|
||||||
|
Separately, the encrypted file was decrypted with the host identity and diffed against the live private keys before anything was changed.
|
||||||
|
|
||||||
|
**Task 0010's handoff about `authorizedKeys` is deliberately left open.**
|
||||||
|
That note proposed settling it here, on the grounds that SSH is not a recovery route while no key is authorized.
|
||||||
|
It is not an acceptance criterion of this task, and choosing which public key to trust is the operator's call rather than one to infer.
|
||||||
|
It wants its own task, and remains a real gap: a decryption failure that locks the console still has no network fallback.
|
||||||
68
.claude/tasks/0012-secrets-operations-docs.md
Normal file
68
.claude/tasks/0012-secrets-operations-docs.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
spec: sops-secrets
|
||||||
|
blocked-by: [0010-sops-skeleton-and-password, 0011-neogaia-ssh-host-keys]
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
A thorough revision of the install document, not an appendix to it.
|
||||||
|
|
||||||
|
The existing procedure is built around a login password set by hand through `nixos-enter` after the install and never committed.
|
||||||
|
That step no longer exists, so the parts of the document that describe it are wrong rather than merely incomplete: the framing that names two hand-entered secrets, the step that sets the bootstrap password, the reboot step's instruction to log in with it, and the closing follow-up section — which additionally describes the superseded key-derivation mechanism as the reason sops wiring cannot happen during an install.
|
||||||
|
The LUKS passphrase remains the one secret genuinely entered by hand, and the revised document should say so plainly.
|
||||||
|
|
||||||
|
The install ordering inverts.
|
||||||
|
A host's identity is now provisioned and registered *before* its first boot, because the login password arrives only from a decrypted secret and there is no fallback credential — a first boot without a registered identity has no way in.
|
||||||
|
The document should carry that as the reason, since it is the whole point of the reordering.
|
||||||
|
|
||||||
|
Cover four procedures:
|
||||||
|
|
||||||
|
- Provisioning a host that is already installed and running, done live on the machine: generate the identity, add its recipient, re-key the affected files with the admin identity, rebuild. No reimage, no live ISO.
|
||||||
|
- Provisioning a host that does not yet exist, done on the live ISO before the install: generate the identity, add its recipient, re-key, write the identity onto the target root, then install. The install builds from a local clone, so no push is required mid-procedure; the recipient change is committed afterward.
|
||||||
|
- The editing workflow: which secrets the workstation can change on its own, and which require unlocking the admin identity for the session. That friction is intended, not an oversight.
|
||||||
|
- Recovery from a live ISO for a machine whose identity was provisioned wrongly, so a failed first boot is a known procedure rather than an improvised one.
|
||||||
|
|
||||||
|
Everything goes in the existing install document; no new document is introduced.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] No step remains that sets a login password by hand, and nothing instructs the operator to log in with one
|
||||||
|
- [x] The document's framing names the LUKS passphrase as the only hand-entered secret
|
||||||
|
- [x] The install ordering places identity provisioning before first boot, and states why there is no fallback credential
|
||||||
|
- [x] The closing section no longer describes deriving identities from SSH host keys or defers sops wiring to a post-boot follow-up
|
||||||
|
- [x] Live provisioning for an already-running host is documented
|
||||||
|
- [x] Pre-install provisioning on the live ISO for a not-yet-existing host is documented, including that the recipient change is committed after the install
|
||||||
|
- [x] The editing workflow is documented, distinguishing what the workstation can re-key alone from what needs the admin identity
|
||||||
|
- [x] The live-ISO recovery path for a wrongly-provisioned machine is documented
|
||||||
|
- [x] The document reads end to end as one coherent procedure for a reader who has never seen the previous version
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
**The document was retitled and given a table of contents.**
|
||||||
|
Three of the four procedures are not installs, so "Installing `neogaia`" no longer described the contents.
|
||||||
|
It is now "Installing and provisioning a host", and the install procedure is one section among four rather than the whole document.
|
||||||
|
The install steps stay concrete about `neogaia` and its NVMe device, since that is the only machine the flake installs today and a generic example would be less useful than a real one.
|
||||||
|
|
||||||
|
**Commands were verified against the running system rather than written from memory.**
|
||||||
|
Neither `sops` nor `age` is packaged by this flake, so every invocation goes through `nix run nixpkgs#sops` or `nix shell nixpkgs#age -c age-keygen`, and the document says so up front.
|
||||||
|
`sops updatekeys`, `age-keygen -y`, and `nixos-install --root/--flake/--no-root-password` were each confirmed to exist.
|
||||||
|
The identity file's `0400 root:root` and `/var/lib/sops-nix/key.txt` were read off the live machine, and `/var` was confirmed to sit on the `@root` subvolume, which is what makes the path valid before user creation.
|
||||||
|
|
||||||
|
**Review caught four factual errors, all corrected.**
|
||||||
|
The most consequential: the post-provisioning check said `ls -l /run/secrets/`, but `neededForUsers` puts the password hash in `/run/secrets-for-users/` — confirmed by `nix eval`, which returns `/run/secrets-for-users/alexion-password`.
|
||||||
|
The one secret whose failure causes the lockout the document exists to prevent was the one the reader was told not to look at.
|
||||||
|
Also fixed: the `.sops.yaml` example added a new host to the shared rule but not a rule for its own file, which makes `sops` refuse it with `no matching creation rules found`; step 8's `updatekeys` omitted `SOPS_AGE_KEY_FILE`, so it would have looked in `~/.config/sops/age/keys.txt` rather than the root-owned identity; and "create its file now" did not say that a host enabling the SSH daemon needs `ssh-host-<type>-key` entries or the build fails at evaluation.
|
||||||
|
|
||||||
|
**Deliberate redundancy in the recovery path.**
|
||||||
|
Review flagged the disko remount block and the re-key sequence as duplicated between the install and recovery sections.
|
||||||
|
They are left duplicated on purpose: an operator running the recovery procedure is locked out of the machine, and sending them to page back into the install steps mid-recovery is worse than the maintenance cost of two copies.
|
||||||
|
The already-running-host procedure does cross-reference step 4, because that reader has a working machine and can follow a link.
|
||||||
|
|
||||||
|
**The recovery procedure is documented but unexercised.**
|
||||||
|
Both branches follow from verified facts — `nixos-install` is idempotent and reuses the formatted disk, and the secrets file is baked into the closure at build time, which is why one branch needs a rebuild and the other does not.
|
||||||
|
Neither has been run, because doing so requires deliberately locking out the only machine.
|
||||||
|
The claim that no fallback credential exists was checked rather than assumed: there is no `authorizedKeys`, no root password, and `mutableUsers = false`.
|
||||||
|
|
||||||
|
**Follow-up.**
|
||||||
|
Task 0019 adds user SSH keys, which will make "no authorized SSH key" in the no-fallback paragraph stale.
|
||||||
|
That paragraph is the place to revisit when it lands, since an authorized key would become a genuine second way in.
|
||||||
40
.claude/tasks/0013-nixos-hardware-profile.md
Normal file
40
.claude/tasks/0013-nixos-hardware-profile.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
## What to build
|
||||||
|
|
||||||
|
Hand ownership of `neogaia`'s hardware facts to the upstream `nixos-hardware` profile for the Dell XPS 13 9380, replacing settings this repo currently guesses or omits.
|
||||||
|
|
||||||
|
The profile is adopted wholesale, including the Intel GPU support it pulls in. Those packages are inert on a machine with no display server, and trimming them would mean diverging from upstream for no present benefit.
|
||||||
|
|
||||||
|
Adopting it makes four things true that are false on the running machine today: the laptop suspends into deep S3 rather than s2idle, the redundant PS/2 mouse driver stops loading over the i2c touchpad, thermal management runs, and firmware updates become possible.
|
||||||
|
|
||||||
|
The microcode setting the `Host` currently declares is dropped, because the profile provides it as a default keyed off the redistributable firmware setting already enabled here.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `nixos-hardware` is a flake input
|
||||||
|
- [x] The Dell XPS 13 9380 profile is imported by the `neogaia` `Host`
|
||||||
|
- [x] The `Host`'s own Intel microcode setting is removed, now that the profile supplies it
|
||||||
|
- [x] `nix flake check` builds the `neogaia` toplevel
|
||||||
|
- [x] Manual confirmation after a rebuild: the default sleep mode is deep rather than s2idle
|
||||||
|
- [x] Manual confirmation after a rebuild: the thermal and power management services are active, and the PS/2 mouse driver is no longer loaded
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
Both manual criteria were confirmed on the rebooted machine. The selected sleep
|
||||||
|
mode moved from s2idle to deep, with the kernel parameter visible on the boot
|
||||||
|
command line; the thermal and power management services came up active; and the
|
||||||
|
PS/2 mouse module is no longer loaded. The booted system, the running system and
|
||||||
|
the freshly built toplevel are all the same store path, so these readings come
|
||||||
|
from this configuration rather than a surviving older generation.
|
||||||
|
|
||||||
|
The firmware update service reads as inactive, which is correct rather than a
|
||||||
|
failure: it is activated on demand over D-Bus. Its unit is present, its refresh
|
||||||
|
timer is enabled, and its command-line tool is on the path.
|
||||||
|
|
||||||
|
The input follows the base nixpkgs. Locking it without that pulled a second
|
||||||
|
nixpkgs into the lock file, which nothing evaluates — only the NixOS modules are
|
||||||
|
consumed — and which would drift silently. Following matches every other input
|
||||||
|
here except chaotic, whose separate pin is deliberate.
|
||||||
|
|
||||||
|
Intel microcode updates now rest on the profile's default rather than an explicit
|
||||||
|
setting here. The default is overridable, so a `Host` that disables redistributable
|
||||||
|
firmware would silently lose microcode updates too.
|
||||||
30
.claude/tasks/0014-nix-store-housekeeping.md
Normal file
30
.claude/tasks/0014-nix-store-housekeeping.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
## What to build
|
||||||
|
|
||||||
|
Bound the three things on this machine that currently grow without any limit: the Nix store, the set of retained system generations, and the boot menu.
|
||||||
|
|
||||||
|
Garbage collection runs weekly, deleting generations older than 30 days. That window is the point of the setting — on a rolling channel with a third-party kernel, the value of an old generation is having a known-good system to boot when an update breaks something, and disk space is not scarce here: the store is under 5 GiB against 473 GiB free.
|
||||||
|
|
||||||
|
Store optimisation runs weekly on its own schedule rather than at build time, so deduplication never adds latency to a rebuild.
|
||||||
|
|
||||||
|
Retained boot configurations are capped at 15. Each generation stores a kernel and an initrd on the EFI system partition at roughly 70 MiB apiece, and that partition is small and fixed. An exhausted one fails at bootloader installation — after the build has already succeeded, which is a confusing place to get stuck. The cap assumes the enlarged partition; on the current 512 MiB one only about seven fit.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Automatic garbage collection is enabled weekly, deleting generations older than 30 days
|
||||||
|
- [x] Store optimisation is scheduled weekly, rather than performed at build time
|
||||||
|
- [x] Retained boot configurations are capped at 15
|
||||||
|
- [x] These are declared as plumbing in the shared base config, so every future `Host` inherits them
|
||||||
|
- [x] `nix flake check` builds the `neogaia` toplevel
|
||||||
|
- [x] Manual confirmation after a rebuild: the collection and optimisation timers exist and are scheduled
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
The two schedules are named days rather than the bare `weekly` keyword.
|
||||||
|
systemd expands `weekly` to `Mon *-*-* 00:00:00`, which would have started collection and deduplication at the same instant every week, leaving `nix-optimise` hard-linking paths `nix-gc` was concurrently deleting.
|
||||||
|
Collection now runs `Mon 03:15` and optimisation `Thu 03:45`, which keeps both weekly and keeps them apart.
|
||||||
|
|
||||||
|
The boot configuration cap sits in the shared base as the task asks, and is inert rather than an error on a host that does not use systemd-boot.
|
||||||
|
A future host on another bootloader therefore inherits no cap, which is the one place the "every future host inherits them" promise does not reach.
|
||||||
|
|
||||||
|
Confirmed on `neogaia` after a switch: `systemctl list-timers 'nix-*'` lists both units, `nix-optimise` next on Thursday and `nix-gc` next on Monday, each `Persistent=true` so a suspended laptop catches up on a missed firing.
|
||||||
|
`/boot` reports 2 GiB with 113 MiB used, so the cap of 15 sits against the enlarged partition it assumes.
|
||||||
42
.claude/tasks/0015-git-module.md
Normal file
42
.claude/tasks/0015-git-module.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
## What to build
|
||||||
|
|
||||||
|
A git `Module`, following the `Enable convention` and configured natively through home-manager, that carries the operator's commit identity — enabled on `neogaia`.
|
||||||
|
|
||||||
|
Today that identity exists only in one repository's local configuration on one machine. It is therefore invisible to every other checkout, absent from any future `Host`, and lost on a reimage. Declaring it makes committing work anywhere, reproducibly, like everything else in the flake.
|
||||||
|
|
||||||
|
The identity matches the one already present throughout this repository's history, so existing commits and future ones agree. Committing it is not a disclosure: it appears in every commit this repository has ever published.
|
||||||
|
|
||||||
|
It is a `Module` rather than base plumbing because a `Host` that should not carry a personal commit identity is easy to imagine once the servers exist.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A git `Module` following the `Enable convention` exists and is enabled on `neogaia`
|
||||||
|
- [x] The commit identity is configured through home-manager and matches the one used in existing history
|
||||||
|
- [x] `nix flake check` builds the `neogaia` toplevel
|
||||||
|
- [x] Manual confirmation: committing in a repository outside this checkout succeeds with no per-command identity override
|
||||||
|
- [x] The stale note in the project's agent instructions claiming git identity is unconfigured is corrected, since commits already work here through repository-local configuration
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
`programs.git.userName`/`userEmail` are renamed in this home-manager pin and emit an obsolete-option trace.
|
||||||
|
The module uses `settings.user.name`/`settings.user.email`.
|
||||||
|
Do not "fix" it back.
|
||||||
|
|
||||||
|
Review on the pull request asked for the module on every host, which was first built by defaulting `enable` to true and dropping the per-host line.
|
||||||
|
The operator then chose the opposite: `enable` defaults to false and each host enables it explicitly, so a host keeps reading as a full checklist of what it carries rather than hiding a default-on module.
|
||||||
|
Enabling it is therefore a step when adding a host.
|
||||||
|
|
||||||
|
The commit name is the literal `"alexion"` rather than `config.user.name`, which review raised as duplication.
|
||||||
|
A Unix login and a commit display name are separate concepts that merely coincide here, so binding them would let a host overriding its login silently rewrite the operator's commit identity.
|
||||||
|
|
||||||
|
The manual confirmation is met on the running machine.
|
||||||
|
The operator rebuilt `neogaia`, `~/.config/git/config` is now a home-manager symlink, and a commit in a repository outside this checkout was authored `alexion <contact@alexion.dev>` in the real environment with no per-command override and no identity in the test repository's own config.
|
||||||
|
|
||||||
|
Review surfaced an unanticipated hazard that proved harmless.
|
||||||
|
Home-manager writes `~/.config/git/config`, while an undeclared `~/.gitconfig` also exists and outranks it on any key set in both.
|
||||||
|
It holds only a `tea` credential helper and no `user.*`, so it does not shadow the identity, confirmed against the deployed configuration.
|
||||||
|
Declaring that credential helper is a reasonable follow-up, since it will not survive a reimage.
|
||||||
|
|
||||||
|
This checkout's `.git/config` still sets the same identity, now redundant.
|
||||||
|
Removing it would let the module govern here too, so a future breakage surfaces instead of being masked.
|
||||||
|
It is local, untracked state, so it is left alone rather than changed as part of this task.
|
||||||
59
.claude/tasks/0016-esp-resize-and-reimage.md
Normal file
59
.claude/tasks/0016-esp-resize-and-reimage.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
blocked-by: 0013-nixos-hardware-profile
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Grow `neogaia`'s EFI system partition and reimage the laptop from the finished configuration.
|
||||||
|
|
||||||
|
The partition is 512 MiB today, holding about seven generations at roughly 70 MiB of kernel and initrd apiece, and this `Host` runs a large third-party kernel. It grows to 2 GiB, which holds around 28 — comfortably past the 15 that are retained, at a cost of 0.3% of a 512 GB disk.
|
||||||
|
|
||||||
|
It cannot be grown in place: it sits first on the disk, starting at sector 2048 with the encrypted container immediately behind it, so enlarging it means moving that container's start offset. An encrypted volume's start cannot be relocated without rewriting its entire payload, which here is over 500 GiB. A reimage is the only practical route, and it is cheapest now — the machine is days old and holds around 3 GiB, of which 136 MiB is user data.
|
||||||
|
|
||||||
|
The hardware profile blocks this because it is the one boot-affecting change queued: it adds a kernel parameter and blacklists a module. Proving it boots while a known-good generation still exists to roll back to means the reimage installs a configuration already known to work on this hardware. A freshly imaged machine has one generation and no rollback target, which is the wrong place to discover a bad kernel parameter.
|
||||||
|
|
||||||
|
Nothing else blocks it. The housekeeping and commit-identity changes carry no boot risk and apply in seconds on either side of the wipe, so they must not be allowed to delay it — the case for reimaging now rests on the machine still holding almost nothing, and that erodes with every day of use.
|
||||||
|
|
||||||
|
This reimage is also the reproducibility test of the install documentation. The first install was performed while writing it; performing it a second time against the current configuration is what proves it is a procedure rather than a record of one improvised session.
|
||||||
|
|
||||||
|
One thing must be true before the disk is erased: every branch worth keeping has to exist on the remote, because work that lives only on this disk dies with it.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] The `Host`'s disk layout declares a 2 GiB EFI system partition
|
||||||
|
- [x] Every local branch worth keeping exists on the remote before the disk is erased
|
||||||
|
- [x] The reimage is performed from a configuration carrying the hardware profile, following the existing install documentation
|
||||||
|
- [x] The install documentation is corrected wherever the procedure diverged from what it describes
|
||||||
|
- [x] Manual confirmation: the machine boots, the encrypted root unlocks, and console login succeeds
|
||||||
|
- [x] Manual confirmation: reported free space on the boot partition is consistent with its 2 GiB size, resolving the discrepancy observed before the reimage — where a 512 MiB partition reported 1022 MiB
|
||||||
|
- [x] The project's agent instructions record that a flake only sees git-tracked files, so an untracked file is invisible to evaluation
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
Done. The reimage was performed by the operator and the machine now runs the
|
||||||
|
configuration this repository declares.
|
||||||
|
|
||||||
|
The install ran clean: the operator reports no step diverged from
|
||||||
|
`docs/install.md`, so criterion 4 is satisfied with no further corrections. The
|
||||||
|
four corrections that landed earlier came from reading the procedure; the run
|
||||||
|
itself found nothing to add. That is the reproducibility evidence the task was
|
||||||
|
after — the document is a procedure, not a record of one improvised session.
|
||||||
|
|
||||||
|
Verified on the running machine rather than assumed:
|
||||||
|
|
||||||
|
- `/dev/nvme0n1p1` is 2.0 GiB and `df` reports 2.0 GiB. The pre-reimage
|
||||||
|
discrepancy, where a 512 MiB partition reported 1022 MiB, is gone.
|
||||||
|
- The hardware profile is live — `mem_sleep_default=deep` is on the kernel
|
||||||
|
command line and `psmouse` is blacklisted and not loaded.
|
||||||
|
- `cryptroot` is open on `nvme0n1p2` with btrfs mounted, reached through a
|
||||||
|
console login, so the boot-unlock-login path is exercised end to end.
|
||||||
|
- One generation exists (`system-1-link`), confirming a fresh install rather
|
||||||
|
than a rebuild of the prior system.
|
||||||
|
|
||||||
|
The ordering hazard closed favourably: the declaration and the install landed
|
||||||
|
close enough together that the repository never asserted a layout the disk
|
||||||
|
lacked for long.
|
||||||
|
|
||||||
|
Two items outside the repo did not survive the wipe, as anticipated, and neither
|
||||||
|
is covered by a criterion: the wifi credentials, and the agent memory directory
|
||||||
|
— confirmed empty after the reimage.
|
||||||
40
.claude/tasks/0017-hardware-detection-refresh.md
Normal file
40
.claude/tasks/0017-hardware-detection-refresh.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
blocked-by: 0016-esp-resize-and-reimage
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Replace `neogaia`'s hand-written hardware detection file with a real scan of the machine it describes.
|
||||||
|
|
||||||
|
The file was written before the laptop ran NixOS, as an educated guess at what a Dell XPS 13 9380 needs, and still says so. The guess turned out to be adequate — the module required to reach the encrypted root is present and working — so this is honesty maintenance rather than a fix. It matters because the next person to read the file, including a future reader of this repo, should be able to trust that it describes measured hardware.
|
||||||
|
|
||||||
|
Only the detection results are kept: the modules the initrd needs, the modules the kernel loads, and the platform. The generated output also contains filesystem and swap declarations, which are dropped — the declarative disk layout owns those, produces them on every evaluation, and a second stale definition would either conflict outright or silently disagree.
|
||||||
|
|
||||||
|
Generating the scan requires root on the target machine.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] The detection file's contents come from a scan of the running machine rather than a guess
|
||||||
|
- [x] Filesystem and swap declarations are absent from it, leaving the disk layout as the sole source of those
|
||||||
|
- [x] The file no longer describes itself as a placeholder, and says plainly what it holds
|
||||||
|
- [x] `nix flake check` builds the `neogaia` toplevel
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
**The guess was wider than the measurement, not narrower.**
|
||||||
|
It named `thunderbolt`, `usb_storage`, and `sd_mod`, none of which the scan reports; the scan adds `rtsx_pci_sdmmc` for the card reader.
|
||||||
|
Nothing needed to reach the root device was missing, so the guess was adequate as the task assumed, but it was not accurate.
|
||||||
|
`sd_mod` survives in the resolved list regardless, supplied by nixpkgs' own defaults; `thunderbolt` and `usb_storage` now genuinely go, and they matter only for booting from external media, which this machine does not do.
|
||||||
|
|
||||||
|
**Two further lines from the scan were dropped beyond the filesystem and swap declarations the task named.**
|
||||||
|
`boot.initrd.luks.devices."cryptroot".device` is derived by the disk layout, which the layout file already states, so keeping it would have created the same duplicate definition the task drops the filesystems to avoid.
|
||||||
|
`hardware.cpu.intel.updateMicrocode` falls outside the three things the task keeps, and the hardware profile supplies it anyway.
|
||||||
|
Both were checked rather than assumed: after the change the LUKS device, all four filesystems, and microcode all still resolve.
|
||||||
|
|
||||||
|
**The header was rewritten twice.**
|
||||||
|
Its first form enumerated the file's three attributes, which the repo's comment convention names as a feature inventory and forbids in a file-top header.
|
||||||
|
It now carries provenance and the absence pointer only.
|
||||||
|
|
||||||
|
**Verified by a boot.**
|
||||||
|
`nix flake check` proves only that the configuration evaluates and builds, so the reduced initrd was exercised on the machine: it unlocked LUKS and mounted the btrfs root unaided.
|
||||||
|
The running system's store path matches this configuration's build exactly, confirming the boot used it rather than an earlier generation.
|
||||||
64
.claude/tasks/0018-agent-sudo-credential-caching.md
Normal file
64
.claude/tasks/0018-agent-sudo-credential-caching.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
## What to build
|
||||||
|
|
||||||
|
Make root-requiring work reachable from an agent session without waiving the password, by sharing sudo's credential cache across sessions and failing loudly when it is cold.
|
||||||
|
|
||||||
|
Sudo caches an authentication for a timeout window, but keys that cache by terminal under its default `timestamp_type=tty`.
|
||||||
|
An agent's commands run in subprocesses on a different terminal, so a `sudo -v` typed in the operator's shell is invisible to them and every privileged command fails.
|
||||||
|
Setting `timestamp_type=global` keys the cache per user instead, so one authentication covers the whole machine for the window.
|
||||||
|
The timeout is raised to 60 minutes so a session needing root authenticates once rather than every five.
|
||||||
|
|
||||||
|
No `NOPASSWD` rule is introduced, and this is the point of the design.
|
||||||
|
The password remains genuinely required; only its cache is shared.
|
||||||
|
A `NOPASSWD` entry for `nixos-rebuild` would be indistinguishable from blanket root on this machine, since anything able to edit the flake and then rebuild it owns the system.
|
||||||
|
|
||||||
|
The tradeoff is real and bounded: during the window, any process running as the operator can use the cached credential, not only the agent.
|
||||||
|
That is acceptable on a single-user personal laptop where the agent already runs as that user, and it is the reason this belongs to a laptop rather than to any future server `Host`.
|
||||||
|
|
||||||
|
The second half is failure behaviour.
|
||||||
|
A cold cache today surfaces as a bare non-zero exit with no output, which reads as an unexplained stall: the operator has to notice the agent is stuck and then work out what it wanted.
|
||||||
|
A `PreToolUse` hook probing `sudo -n true` turns that into an immediate, actionable refusal naming the command to run.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [-] `timestamp_type=global` and a 60-minute timeout are declared as plumbing in the shared base config
|
||||||
|
- [x] No `NOPASSWD` rule is introduced, and the wheel group still requires a password
|
||||||
|
- [x] Confirmed that NixOS does not already set `timestamp_type` elsewhere, so the declaration is not silently overridden
|
||||||
|
- [x] A `PreToolUse` hook in the claude-code `Module` denies a privileged command when the cache is cold, naming `sudo -v` in its message
|
||||||
|
- [x] The hook's behaviour is correct when the harness sandbox, rather than a cold cache, is what blocks the command
|
||||||
|
- [x] `nix flake check` builds the `neogaia` toplevel
|
||||||
|
- [x] Manual confirmation after a rebuild: `sudo -v` in one terminal lets a privileged command succeed from an agent session, and that command fails with the hook's message once the window lapses
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
**The sudo settings are declared in the claude-code module, not in the shared base config.**
|
||||||
|
The criterion asking for the shared base contradicted this task's own rationale, which argues the widened cache suits a single-user machine and should not reach a future server.
|
||||||
|
Neither placement was right, though: the setting and the hook that depends on it belong together.
|
||||||
|
The hook reads the credential cache from a process of its own, which only works under `timestamp_type=global`, so a host enabling the module without the sudo half would get a hook that never sees a cached credential and refuses every privileged command permanently.
|
||||||
|
Declaring both under the module's `enable` makes that impossible to get wrong, and carries the setting to any future host that runs the agent.
|
||||||
|
|
||||||
|
The cost is that enabling a developer tool now changes the machine's sudo posture, which a reader auditing sudo policy would not expect to find there.
|
||||||
|
The enable option's description carries the warning so it surfaces in generated documentation.
|
||||||
|
Should a host ever need the agent without the widened cache, that is when a separate sub-option earns its place; adding one now would be speculative.
|
||||||
|
|
||||||
|
**Verified against the running system, in both cache states.**
|
||||||
|
Cold, the hook refuses with its message; warm, it permits and the command uses a credential authenticated in a different terminal.
|
||||||
|
The hook process is not itself sandboxed, so it reads the real cache rather than refusing unconditionally — the failure mode that would have required it to fail open instead.
|
||||||
|
|
||||||
|
One residual is worth knowing.
|
||||||
|
The hook governs whether a privileged command is attempted, not whether it can run: the agent's own sandbox blocks `sudo` separately, and swallows it into a bare exit with no output.
|
||||||
|
A permitted command can therefore still fail for that unrelated reason, and needs the sandbox disabled.
|
||||||
|
The two are distinguishable in practice, since only one of them produces the hook's message.
|
||||||
|
|
||||||
|
**The operator must authenticate from a real terminal.**
|
||||||
|
Warming the cache from inside an agent session does not work: that shell has no controlling terminal, so sudo cannot prompt and reports `a terminal is required to read the password`.
|
||||||
|
Feeding the password by another route was rejected rather than unexplored.
|
||||||
|
Reading it from the agent's stdin would route it through the agent, and an askpass helper on this console-only machine could only prompt on the pane the agent already draws to, which trains the operator to type a password into an agent-controlled surface.
|
||||||
|
A separate terminal is the only safe channel, which is precisely what the global cache keying exists to make useful.
|
||||||
|
|
||||||
|
**`jq` is now a home package.**
|
||||||
|
The hook parses the tool input handed to it on stdin, and nothing on the profile provided a JSON parser.
|
||||||
|
Matching on the raw JSON text with `grep` was rejected: a command containing quotes or newlines would break it, and this hook fails closed, so a parsing mistake blocks real work.
|
||||||
|
|
||||||
|
**The sudo detection is anchored to command position.**
|
||||||
|
`grep sudo /etc/passwd` and `echo "run sudo -v"` are allowed; `sudo x`, `cd /tmp && sudo x`, and `true; sudo x` are blocked.
|
||||||
|
A `sudo` inside a quoted string that happens to sit in command position will still trip the guard, which errs toward asking rather than stalling.
|
||||||
87
.claude/tasks/0019-user-ssh-keys-and-access-policy.md
Normal file
87
.claude/tasks/0019-user-ssh-keys-and-access-policy.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
blocked-by: 0011-neogaia-ssh-host-keys
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
The operator's SSH client key becomes a secret, and which machines may reach which becomes a declared policy rather than a hand-edited list.
|
||||||
|
|
||||||
|
Today the key that authenticates pushes to the remote exists only as a file created by hand on one laptop.
|
||||||
|
It is in no secrets file and no module, so a reimage destroys it.
|
||||||
|
That is worse than losing a host key: a lost host key makes clients complain about `known_hosts`, whereas a lost client key locks the operator out of the remote until a new one is generated and registered through the forge's web interface.
|
||||||
|
|
||||||
|
Each machine gets its **own** client identity rather than one shared across the fleet.
|
||||||
|
The private half lives in that machine's own secrets file, so it is readable by that machine and the admin identity alone.
|
||||||
|
A compromised machine therefore surrenders only its own key, and withdrawing a machine's access means removing one public key rather than re-keying every other machine.
|
||||||
|
The public halves are committed in plaintext, as the host public keys are, since publishing them is their function.
|
||||||
|
|
||||||
|
The private half decrypts at activation and is readable only by the primary user.
|
||||||
|
Following the host keys, the client is pointed at the decrypted path rather than having a copy written into the user's home, so there is one authoritative location for the key and no copy to drift.
|
||||||
|
|
||||||
|
Access is expressed as a policy over machine roles, not as a per-host list of authorized keys.
|
||||||
|
A **workstation** may reach every machine in the fleet.
|
||||||
|
A **server** may reach other servers only.
|
||||||
|
Consequently every machine authorizes the workstation keys, and servers additionally authorize the server keys, while a workstation never authorizes a server's key — so a compromised server cannot reach the operator's own machines.
|
||||||
|
|
||||||
|
This wants a single declaration of the fleet, naming each machine's role and its client public key, from which every host derives the set it authorizes.
|
||||||
|
Registering a new machine is then declaring its role in one place, rather than an edit to every other host's configuration.
|
||||||
|
|
||||||
|
Only `neogaia` exists today, so the server half of the policy has nothing to act on and cannot be exercised.
|
||||||
|
It is built and recorded now so that the desktop and the three planned servers are a role declaration rather than a redesign.
|
||||||
|
|
||||||
|
Adopt the key already present on `neogaia` rather than generating a fresh one.
|
||||||
|
It is already registered with the remote, so adopting it keeps pushes working, whereas replacing it would require registering the new key through the web interface before the old one stops being used — an ordering that locks the operator out if it goes wrong.
|
||||||
|
Machines that do not exist yet generate their own key during provisioning, alongside the age identity.
|
||||||
|
|
||||||
|
This also settles the gap left open by task 0010, where the daemon accepts connections but authorizes no key, so a failed decryption that locks the console has no network fallback.
|
||||||
|
Note that the fallback only becomes real once a second machine exists to connect from.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `neogaia` has its own client SSH identity, distinct from its host keys, adopted from the key already on the machine
|
||||||
|
- [x] Its private half is stored in `neogaia`'s own secrets file, encrypted to the admin identity and `neogaia` alone
|
||||||
|
- [x] Its public half is committed in plaintext
|
||||||
|
- [x] The private half decrypts at activation, readable only by the primary user and not by other accounts
|
||||||
|
- [x] The SSH client uses the decrypted key with no hand-placed copy in the user's home directory
|
||||||
|
- [x] Each machine declares a role, and the keys it authorizes follow from that role rather than from a per-host list
|
||||||
|
- [x] Workstation keys are authorized on every machine
|
||||||
|
- [x] Server keys are authorized on servers only, and on no workstation
|
||||||
|
- [x] Registering a new machine is a role declaration in one place, requiring no edit to any other host
|
||||||
|
- [x] `nix flake check` builds the `neogaia` toplevel
|
||||||
|
- [x] Manual confirmation: the key materializes with the declared ownership and mode, an authenticated push to the remote still succeeds, and `neogaia` accepts an SSH connection offering the adopted key
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
The whole policy is three options on the ssh module.
|
||||||
|
Two are the lists of client public keys, one for the machines the operator works from and one for the machines that serve.
|
||||||
|
The third is the set a machine admits, which a host declares in its own file by naming the lists it draws from.
|
||||||
|
|
||||||
|
Two earlier designs were discarded as more machinery than the problem has.
|
||||||
|
The first was a separate fleet declaration mapping each machine to a role and a key, which the module looked up by hostname.
|
||||||
|
The second kept the two lists but derived the admitted set from a role enum.
|
||||||
|
Authorizing a key needs the key text and nothing else, so the per-machine names, the hostname lookup and the role all existed to reconstruct a grouping that the two lists simply are.
|
||||||
|
A host now states what it admits rather than stating a category that something else maps to keys.
|
||||||
|
|
||||||
|
`authorizedKeys` defaults to the workstation keys.
|
||||||
|
An option of a list type is not required in the way a scalar one is: leaving it undeclared yields the empty list rather than an evaluation error, and a machine admitting no key is unreachable over SSH.
|
||||||
|
The default makes the safe case the silent one.
|
||||||
|
|
||||||
|
Only `neogaia` exists, so the server half has nothing to act on.
|
||||||
|
It was verified by temporarily adding a synthetic server key and declaring both lists on the host, then reverting.
|
||||||
|
A host drawing on the workstation keys alone excluded the server key, and one drawing on both admitted it.
|
||||||
|
Omitting the declaration entirely was confirmed to fall back to the workstation keys rather than to none.
|
||||||
|
|
||||||
|
Home-manager's `matchBlocks` is deprecated in favour of `settings`, so the client uses the latter.
|
||||||
|
`enableDefaultConfig = false` drops home-manager's own default directives, leaving the generated `~/.ssh/config` at two lines and every other directive at the value OpenSSH itself ships.
|
||||||
|
|
||||||
|
The committed public key carries the comment `alexion@neogaia` rather than the adopted key's own `contact@alexion.dev`, so the list says which machine each key belongs to.
|
||||||
|
An authorized-keys comment is free text and independent of the private key.
|
||||||
|
|
||||||
|
Manual confirmation was performed after a `nixos-rebuild switch`.
|
||||||
|
The secret materialized as `-r--------` owned by the primary user, and the public half derived from it matches the committed fleet entry.
|
||||||
|
The hand-placed `~/.ssh/id_ed25519` was moved aside for the test, so both directions were exercised against the decrypted secret alone: `ssh -v` to the remote reported `Server accepts key: /run/secrets/ssh-user-ed25519-key`, and an inbound connection to `neogaia` authenticated and returned a shell.
|
||||||
|
|
||||||
|
One follow-up is outstanding.
|
||||||
|
Deleting the now-redundant `~/.ssh/id_ed25519` and its public half was refused by the agent's permission layer, so both files remain on the machine.
|
||||||
|
They are superseded rather than needed: the same key is in `secrets/neogaia.yaml`, and the client is pointed at the decrypted path.
|
||||||
|
Removing them is a one-line manual step, and the key is recoverable from the secrets file if it is ever wanted back.
|
||||||
27
.claude/tasks/0020-pi-coding-agent-module.md
Normal file
27
.claude/tasks/0020-pi-coding-agent-module.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
spec: pi-coding-agent
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Add a `pi` module, auto-discovered like every other feature and inert until a host enables it, that installs Pi for the primary user through the home-manager `programs.pi-coding-agent` module.
|
||||||
|
On enable it freezes exactly one file — `settings.json` — pinning the default provider to Anthropic and the default model to Opus (the exact model-id string confirmed against Pi's own model catalogue), and disabling analytics.
|
||||||
|
Everything else — agent context, skills, extensions, keybindings, custom providers — is left at its default, so home-manager renders nothing but `settings.json` and Pi owns the rest of `~/.pi/agent/`.
|
||||||
|
Pi authenticates by reusing the existing Claude subscription, and that credential is left unmanaged by the flake so no secret enters the repo and re-auth survives rebuilds, mirroring how the `claude-code` module treats its login.
|
||||||
|
Lay the module out as a directory (not a single file), and enable it on `neogaia` alone with a single `enable = true`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A `pi` module exists as its own directory, declares a single `enable` option under the `modules` tree, guards its body with the Enable convention, and stays inert until a host enables it.
|
||||||
|
- [x] On enable, the module turns on `programs.pi-coding-agent` for the primary user from the base package set, with no other host affected.
|
||||||
|
- [x] The frozen `settings.json` sets the default provider to Anthropic, the default model to Opus (exact model-id verified against Pi's catalogue), and disables analytics — and no other upstream option (`context`, `models`, `keybindings`, `extraPackages`, `configDir`) is set.
|
||||||
|
- [x] Pi's credential and all of its writable state (`~/.pi/agent/` beyond `settings.json`) are left unmanaged by the flake.
|
||||||
|
- [x] `neogaia` enables the module with a single `enable = true` and its system toplevel still builds via `nix flake check` (the `checks.x86_64-linux.neogaia` target).
|
||||||
|
- [x] Disabling the module is a one-line `enable` flip that leaves no flake-managed residue.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Model-id and analytics key confirmed against Pi 0.80.7 at build time.** The `pi-coding-agent` package pins `0.80.7`; its `dist/core/model-resolver.js` defaults the `anthropic` provider to `claude-opus-4-8`, which is the Opus id used. The analytics key is `enableAnalytics` (boolean, default `false`), per the package's own `docs/settings.md`. Both were read from the built store path, not guessed.
|
||||||
|
- **Verified through the primary seam.** `config.modules.pi.enable` and `programs.pi-coding-agent.enable` both evaluate `true` on `neogaia`; the rendered `settings.json` is exactly `{"defaultModel":"claude-opus-4-8","defaultProvider":"anthropic","enableAnalytics":false}`; only one file (`settings.json`) is rendered under `~/.pi/agent`; and `checks.x86_64-linux.neogaia` builds green with `pi-coding-agent-0.80.7` included.
|
||||||
|
- **No deviations from the spec.** The diff is the module plus one `enable = true` line — every "Out of Scope" item (agent context/`AGENTS.md`, skills, extensions, keybindings, custom providers, Pi-specific sudo guard) is left out.
|
||||||
|
- **Review follow-through.** `/review-uncommitted` rated Risk **Low** and Spec **clean**. Standards flagged four comment-convention issues on the new module (a semicolon in a comment, an overloaded file-top header duplicating the inline rationale, and a cross-file clause on the model-id comment); all were fixed in the diff, so the header is now a two-sentence purpose line mirroring the sibling `claude-code` module and the frozen-settings rationale lives only at its inline site. No findings left unaddressed.
|
||||||
67
.claude/tasks/0021-desktop-group-and-hyprland-session.md
Normal file
67
.claude/tasks/0021-desktop-group-and-hyprland-session.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
spec: hyprland-desktop
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
The tracer bullet for the whole desktop: a keyboard-driven Hyprland session that neogaia can log into and open a terminal in.
|
||||||
|
|
||||||
|
Create the `modules/desktop/` group with an explicit aggregator, guarded by its own `modules.desktop.enable`, that hand-lists and turns on each piece at default priority so a host enables the whole desktop with one flag yet can still override any single piece.
|
||||||
|
Namespace every desktop enable under one desktop group so a host's checklist gains one entry.
|
||||||
|
Place the tightly coupled Hyprland-native pieces (starting with the compositor) in a subdirectory within the group.
|
||||||
|
|
||||||
|
Wire Hyprland from nixpkgs: the NixOS program integration owns the session and polkit, home-manager owns the user configuration, and both share one Hyprland package so there is never a version split.
|
||||||
|
The session is launched through the universal Wayland session manager from the greeter.
|
||||||
|
Login is greetd with the tuigreet text greeter, mouse-free and lightweight.
|
||||||
|
|
||||||
|
Port the operator's KDE/i3 keybinds expressed entirely in `hjkl` and letters with no arrow or navigation-cluster keys: numbered-workspace switch and move, focus and window movement, resize, terminal, floating, fullscreen, split, close, and force-kill, per the spec's keybind table.
|
||||||
|
Tune input: US-only layout with no switcher, Caps mapped to Escape with Shift+Caps still producing CapsLock, snappy key-repeat, touchpad tap-to-click plus natural scroll plus disable-while-typing, and flat mouse acceleration.
|
||||||
|
Set the feel: subtle animations, modest rounding, small gaps, and blur off (left as a knob a host such as zeus can enable).
|
||||||
|
Install Ghostty as the terminal on `Super+Return`.
|
||||||
|
|
||||||
|
Enable the desktop on neogaia.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `modules/desktop/` exists with an aggregator exposing `modules.desktop.enable` that hand-lists and enables its pieces at default priority, each piece independently overridable.
|
||||||
|
- [x] Desktop enable options are namespaced under a single desktop group; the Hyprland-native compositor lives in a subdirectory of the group.
|
||||||
|
- [x] Hyprland is sourced from nixpkgs; the NixOS integration and the home-manager user config share one Hyprland package.
|
||||||
|
- [x] The session launches through the universal Wayland session manager from a greetd/tuigreet text login.
|
||||||
|
- [x] Keybinds match the spec's table, using only `hjkl`, letters, and number rows — no arrow or navigation-cluster keys.
|
||||||
|
- [x] Input is tuned: US-only layout, Caps→Escape (Shift+Caps = CapsLock), snappy key-repeat, touchpad tap-to-click + natural scroll + disable-while-typing, flat mouse acceleration.
|
||||||
|
- [x] Animations, rounding, and small gaps are on; blur is off and remains host-overridable.
|
||||||
|
- [x] Ghostty opens on `Super+Return`.
|
||||||
|
- [x] neogaia enables `modules.desktop` and builds green under `nix flake check`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Keybind scope.**
|
||||||
|
This task ports only the enumerated compositor-native bindings (workspace switch/move, focus, window move, resize, terminal, floating, fullscreen, split, close, force-kill).
|
||||||
|
The spec table's launcher, lock, screenshot, clipboard, and record bindings depend on tools installed by later tasks (0024–0029), so each of those tasks adds its own binding rather than this one binding to a missing binary.
|
||||||
|
Force-kill uses Hyprland's `forcekillactive` dispatcher, keeping it keyboard-only.
|
||||||
|
|
||||||
|
- **Shared Hyprland package.**
|
||||||
|
The NixOS `programs.hyprland` installs the single package and the portal system-wide, and the home-manager module sets `package = null` and `portalPackage = null` so it writes only the config against that one package.
|
||||||
|
This is the "never a version split" guarantee, read as one package total rather than two identical derivations.
|
||||||
|
|
||||||
|
- **hyprlang, not Lua.**
|
||||||
|
The home-manager `wayland.windowManager.hyprland` module now defaults `configType` to `"lua"` at `home.stateVersion` ≥ 26.05, which serialises `$mod`-style variables and INI `bind=` strings into invalid Lua without failing the build.
|
||||||
|
The module pins `configType = "hyprlang"` to emit the native `hyprland.conf`.
|
||||||
|
Recorded as a gotcha in `CLAUDE.md`.
|
||||||
|
|
||||||
|
- **Greeter session command.**
|
||||||
|
greetd's `default_session` runs `uwsm start -e -D Hyprland hyprland.desktop`, mirroring the Exec line of the uwsm session the Hyprland package itself ships, so the session goes through the universal Wayland session manager deterministically.
|
||||||
|
|
||||||
|
- **Terminal: Alacritty, not Ghostty.**
|
||||||
|
The spec named Ghostty, but on neogaia's integrated graphics its GTK4 window construction made every launch feel sluggish (~440 ms to map, versus a lightweight terminal's near-instant open), which a head-to-head comparison confirmed.
|
||||||
|
The terminal is therefore Alacritty, whose OpenGL renderer opens fast on the iGPU.
|
||||||
|
The choice is easily reversible per host, so a capable host such as zeus could still adopt Ghostty later.
|
||||||
|
|
||||||
|
- **Dropped from the plan.**
|
||||||
|
Mouse drag-to-move and drag-to-resize (`bindm`) were removed: they fall outside the task's enumerated keyboard bindings, and `resizeactive`/`movewindow` already cover floating windows from the keyboard.
|
||||||
|
Hardware media/brightness keys (the spec table's `XF86` row) are likewise deferred, since they depend on audio and backlight tooling not yet in scope.
|
||||||
|
|
||||||
|
- **Added beyond the plan.**
|
||||||
|
`Super+Shift+T` toggles the tiling strategy between the dwindle and master layouts, added at the operator's request during review.
|
||||||
|
Neither a dispatcher nor a keyword flips the layout on its own, so a small script reads the current layout and sets the other through `hyprctl keyword`.
|
||||||
|
It is not in the spec keybind table.
|
||||||
49
.claude/tasks/0022-stylix-nord-theming.md
Normal file
49
.claude/tasks/0022-stylix-nord-theming.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
spec: hyprland-desktop
|
||||||
|
blocked-by: 0021-desktop-group-and-hyprland-session
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Theme the whole new graphical layer Nord from a single source, and set the wallpaper.
|
||||||
|
|
||||||
|
Add Stylix as a flake input and a desktop theming module that drives colors, system fonts, and cursor from one Nord base16 scheme across the graphical surface (GTK, Qt, and the compositor colors), plus a single static Nord wallpaper set by Stylix.
|
||||||
|
Scope Stylix to the graphical layer only: leave its targets for the existing terminal tools (nvim, tmux, fish) off so their established hand-themes stand unchanged.
|
||||||
|
The theming is reversible per target, so individual surfaces can migrate toward or away from manual theming later.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Stylix is a flake input, wired into the host build.
|
||||||
|
- [x] A desktop theming module resolves a single Nord base16 scheme and applies it to GTK, Qt, cursor, and system fonts.
|
||||||
|
- [x] A single static Nord wallpaper is set by Stylix; no dynamic, animated, or cycling wallpaper.
|
||||||
|
- [x] The Stylix target for nvim is off, leaving its existing theme untouched (narrowed from nvim + tmux + fish during review, see notes).
|
||||||
|
- [x] neogaia builds green under `nix flake check`, and an eval probe confirms the resolved scheme is Nord.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Wallpaper is a generated Nord gradient, not a shipped image.**
|
||||||
|
Stylix requires an `image`, and the spec asks only for "a single static Nord wallpaper".
|
||||||
|
Rather than commit a binary blob or fetch one over the network at build time, the module draws a vertical gradient across the Nord Polar Night shades (`#2E3440` → `#3B4252`) with ImageMagick.
|
||||||
|
It is static, genuinely Nord, and fully reproducible with no external dependency beyond a cached build tool.
|
||||||
|
Swapping in a picture later is a one-line change to `image`.
|
||||||
|
|
||||||
|
- **The nvim target is `nixvim`, not `neovim`.**
|
||||||
|
nvim here is configured through nixvim, so the Stylix target that would theme it is `nixvim`.
|
||||||
|
Disabling `neovim` would have been a no-op and left nvim themed.
|
||||||
|
|
||||||
|
- **Only nvim is excluded from Stylix (narrowed during review).**
|
||||||
|
The task first turned the nvim, tmux, and fish targets all off.
|
||||||
|
In review the operator narrowed that to nvim alone, so tmux and fish are now Stylix-managed.
|
||||||
|
nvim stays off because its `gbprod/nord.nvim` colorscheme is a purpose-built, treesitter-aware theme, richer than the generic base16 mapping Stylix's neovim target would apply.
|
||||||
|
fish had no colour theme of its own, so handing it to Stylix is a clean addition.
|
||||||
|
tmux carried a hand-written Nord status bar, so its colour lines are removed from `extra.conf` and Stylix now themes the status and pane styles, while the operator's minimal layout (session name plus window list, empty right side) is kept and reapplied after Stylix so it still wins.
|
||||||
|
|
||||||
|
- **Cursor generation switched on explicitly.**
|
||||||
|
home-manager now wants `home.pointerCursor.enable` set explicitly rather than inferring it from the presence of cursor settings, so the module sets it to silence the deprecation and keep the build warning-clean (bar the pre-existing benign nixvim `nixpkgs.follows` notice).
|
||||||
|
|
||||||
|
- **System font pinned to JetBrains Mono.**
|
||||||
|
The acceptance criterion asks for "system fonts" without naming one, so the monospace is pinned to JetBrains Mono and the serif/sans/emoji families are left at Stylix's Nord-coherent defaults.
|
||||||
|
|
||||||
|
- **Stylix module imported unconditionally.**
|
||||||
|
`lib.nix` adds `inputs.stylix.nixosModules.stylix` to every host's module set, matching how the other input modules are wired.
|
||||||
|
It stays inert until `stylix.enable` is set, which only the theming module does, only when the desktop is on.
|
||||||
40
.claude/tasks/0023-waybar-status-bar.md
Normal file
40
.claude/tasks/0023-waybar-status-bar.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
spec: hyprland-desktop
|
||||||
|
blocked-by: 0021-desktop-group-and-hyprland-session
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
A Waybar status bar that reads system state at a glance.
|
||||||
|
|
||||||
|
Add a Waybar module to the desktop group, enabled through the aggregator, showing workspaces with per-application icons plus a clock, battery, network, audio, MPRIS media controls, and a do-not-disturb toggle.
|
||||||
|
No overview/exposé plugin: the workspace indicators are sufficient.
|
||||||
|
The do-not-disturb toggle and media controls live in the bar rather than in a separate notification center.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A Waybar module exists in the desktop group and is enabled by the aggregator.
|
||||||
|
- [x] The bar shows workspaces with per-application icons, a clock, battery, network, audio, MPRIS media controls, and a do-not-disturb toggle.
|
||||||
|
- [x] No overview/exposé plugin is used.
|
||||||
|
- [x] neogaia builds green under `nix flake check`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Audio server added beyond the plan.**
|
||||||
|
The task asked only for the bar's audio *widget*, but nothing in the epic provisions an audio server, and a `wireplumber` widget over a machine with no running sink is inert.
|
||||||
|
A small `modules/desktop/audio.nix` therefore enables PipeWire (with the ALSA and PulseAudio compatibility shims and rtkit) under its own `modules.desktop.audio.enable`, wired into the aggregator at default priority like every other piece.
|
||||||
|
It is a distinct concern that could equally live in its own task, so it is flagged here and in the PR for the operator to split out or keep.
|
||||||
|
|
||||||
|
- **Do-not-disturb depends on mako, which lands later.**
|
||||||
|
The `custom/dnd` widget shells out to `makoctl`, whose daemon arrives with the notifications task (0025).
|
||||||
|
The status script pins mako's store path and degrades to "notifications on" whenever no daemon answers, so the widget is inert rather than broken before 0025 and reflects real state the moment mako runs.
|
||||||
|
|
||||||
|
- **Glyphs decoded, not pasted.**
|
||||||
|
Nerd-font module icons are Private-Use-Area codepoints that do not survive an editor paste, so a `g = code: builtins.fromJSON ''"\u${code}"''` helper decodes each one to real bytes.
|
||||||
|
`nerd-fonts.symbols-only` is installed system-wide as the pango fallback for those codepoints, since Stylix's monospace font does not carry them.
|
||||||
|
|
||||||
|
- **Bar launch.**
|
||||||
|
The bar runs as a home-manager systemd user service bound to `graphical-session.target`, which uwsm activates, so it comes up with the session without a compositor `exec-once`.
|
||||||
|
|
||||||
|
- **Runtime checks deferred to the machine.**
|
||||||
|
`nix flake check` proves the config evaluates and the host builds, but the rendered bar, the MPRIS widget, and the audio widget can only be exercised in a live Wayland session on neogaia.
|
||||||
36
.claude/tasks/0024-rofi-launcher.md
Normal file
36
.claude/tasks/0024-rofi-launcher.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
spec: hyprland-desktop
|
||||||
|
blocked-by: 0021-desktop-group-and-hyprland-session
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
A search-everything launcher, so one keybound tool handles launching and utility menus.
|
||||||
|
|
||||||
|
Add a rofi (Wayland fork) module to the desktop group, enabled through the aggregator, combining application-run, binary-run, and window-switch into one prompt, plus math-evaluation and emoji modes.
|
||||||
|
Bind it on `Super+R`.
|
||||||
|
Structure it so it is reusable as the dmenu-style frontend for later utility menus (clipboard history, power menu), and provide a power menu that uses it.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A rofi module exists in the desktop group and is enabled by the aggregator.
|
||||||
|
- [-] rofi combines application-run, binary-run, and window-switch modes, plus math evaluation and emoji.
|
||||||
|
- [x] rofi opens on `Super+R`.
|
||||||
|
- [x] rofi is usable as a dmenu-style frontend for utility menus, and a power menu is provided through it.
|
||||||
|
- [x] neogaia builds green under `nix flake check`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- `rofi-wayland` no longer exists as a separate package: nixpkgs merged the Wayland fork into `pkgs.rofi` (now 2.0.0).
|
||||||
|
The module uses the plain `pkgs.rofi`, which is the Wayland-capable build.
|
||||||
|
- Criterion 2 is deliberately reduced (`[-]`): the launcher is application-launch only, `modi = "drun"`, opened with `rofi -show drun` on `Super+R`.
|
||||||
|
Binary-run, window-switch, math (`rofi-calc`) and emoji (`rofi-emoji`) were all dropped at the operator's direction, and the two plugins removed with them, to keep the prompt as fast and uncluttered as possible.
|
||||||
|
This narrows the parent spec's "search-everything launcher" (user story 7) to a plain application launcher — a reversible choice, since any mode or plugin can be added back later.
|
||||||
|
Application icons are disabled too (`show-icons = false`), since resolving an icon per entry is the largest part of drun's per-launch startup and rofi runs no resident daemon to amortise it.
|
||||||
|
- The dmenu-style reuse is the themed rofi itself, not a separate abstraction: any `rofi -dmenu` invocation reads the same config and Stylix theme, so the power menu — and later clipboard/utility menus — look uniform for free.
|
||||||
|
- The launcher and power-menu keybinds live in this module rather than in `hyprland.nix`, contributed through `settings.bind`, which the module system concatenates with the compositor's own binds in the single `hyprland.conf`.
|
||||||
|
This keeps each command next to its binding and referenced by store path, so a rename cannot silently break the bind.
|
||||||
|
- The power menu is bound to `Super+Shift+X`, chosen by the operator.
|
||||||
|
It pairs with lock on `Super+X` (a key the spec's table does list), while the parent spec's table has no power-menu key of its own.
|
||||||
|
- No Waybar `window-rewrite` icon mapping was added: rofi renders as a Wayland layer-shell overlay, not a tiled window with a class on a workspace, so it never appears on the workspace indicator the convention governs.
|
||||||
|
- The launcher's quick appearance is a Hyprland change, not a rofi one: layer surfaces get their own `layersIn`/`fadeLayersIn` fade at `2`, a step quicker than the `3` windows use, so the launcher fades in without feeling laggy.
|
||||||
42
.claude/tasks/0025-mako-notifications.md
Normal file
42
.claude/tasks/0025-mako-notifications.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
spec: hyprland-desktop
|
||||||
|
blocked-by: 0021-desktop-group-and-hyprland-session
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Notification toasts with do-not-disturb and history recall, so missed notifications can be retrieved.
|
||||||
|
|
||||||
|
Add a mako module to the desktop group, enabled through the aggregator, rendering notification toasts with a do-not-disturb mode and history recall.
|
||||||
|
The do-not-disturb toggle and media controls live in the bar, not in a separate notification-center panel.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A mako module exists in the desktop group and is enabled by the aggregator.
|
||||||
|
- [x] Notification toasts appear, with do-not-disturb and history recall.
|
||||||
|
- [x] No separate slide-out notification-center panel is added.
|
||||||
|
- [x] neogaia builds green under `nix flake check`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Bar side already in place.**
|
||||||
|
The do-not-disturb toggle (`custom/dnd`, calling `makoctl mode -t dnd`) and the MPRIS media controls already live in `modules/desktop/waybar.nix` from task 0024.
|
||||||
|
This task therefore adds only the daemon: `modules/desktop/mako.nix` enables `services.mako` through home-manager and is turned on by the aggregator.
|
||||||
|
The mode name is `dnd` on both sides, so the bar's toggle and the daemon's `[mode=dnd]` section agree.
|
||||||
|
|
||||||
|
- **Colors from Stylix.**
|
||||||
|
Stylix ships a mako target that drives the background, border, text, and progress colors plus the popup font from the one Nord base16 scheme, so the module sets no colors — only behaviour.
|
||||||
|
This mirrors how `rofi.nix` and `theming.nix` defer their palettes to Stylix.
|
||||||
|
|
||||||
|
- **Do-not-disturb keeps history.**
|
||||||
|
The `[mode=dnd]` section sets `invisible=true`, which hides toasts while still recording them, so notifications missed during do-not-disturb remain retrievable.
|
||||||
|
|
||||||
|
- **History recall keybind.**
|
||||||
|
`Super+N` runs `makoctl restore`, popping the last notification back from history keyboard-only, consistent with the rest of the session.
|
||||||
|
`N` is unused by the spec keybind table, and task 0021 established that each later task adds its own binding rather than 0021 binding to a then-missing tool.
|
||||||
|
|
||||||
|
- **No Waybar icon mapping.**
|
||||||
|
The window-rewrite convention covers graphical apps whose windows appear on the workspace indicator; mako renders toasts as a layer-shell overlay with no tiled window and no `hyprctl clients` entry, so there is nothing to match on.
|
||||||
|
|
||||||
|
- **Dropped from the plan.**
|
||||||
|
A `Super+Shift+N` dismiss-all binding was drafted alongside the recall bind but removed as unrequested scope: the acceptance criteria call for history recall, which `restore` alone serves.
|
||||||
46
.claude/tasks/0026-hyprlock-and-hypridle.md
Normal file
46
.claude/tasks/0026-hyprlock-and-hypridle.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
spec: hyprland-desktop
|
||||||
|
blocked-by: 0021-desktop-group-and-hyprland-session
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
A secure lock screen and idle management, so going idle, suspending, or closing the lid always lands at a locked screen.
|
||||||
|
|
||||||
|
Add hyprlock and hypridle modules in the Hyprland-native subdirectory of the desktop group, enabled through the aggregator.
|
||||||
|
hyprlock uses the compositor session-lock protocol so the lock surface is owned by the compositor and survives a locker crash.
|
||||||
|
hypridle is wired for lock-on-idle, screen-off, lock-before-suspend, and lid-close, with tunable timeouts.
|
||||||
|
Bind lock on `Super+X`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] hyprlock and hypridle modules exist in the Hyprland-native subdirectory and are enabled by the aggregator.
|
||||||
|
- [x] hyprlock uses the compositor session-lock protocol.
|
||||||
|
- [x] hypridle triggers lock-on-idle, screen-off, lock-before-suspend, and lid-close, with tunable timeouts.
|
||||||
|
- [x] Lock is bound on `Super+X`.
|
||||||
|
- [x] neogaia builds green under `nix flake check`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **hyprlock is inherently the session-lock client.**
|
||||||
|
Criterion 2 needs no option: hyprlock draws its surface through the ext-session-lock protocol, so the compositor owns the surface and it survives a crash of the locker.
|
||||||
|
The module therefore carries only geometry and behaviour.
|
||||||
|
|
||||||
|
- **Stylix themes the lock screen.**
|
||||||
|
Colors and the lock background come from Stylix's hyprlock target, which merges into the same `background` and `input-field` blocks, so the module sets only field geometry and a `$TIME` label.
|
||||||
|
|
||||||
|
- **lid-close is wired through logind, not a hypridle listener.**
|
||||||
|
hypridle cannot observe lid events, so the module sets `services.logind.settings.Login.HandleLidSwitch = "suspend"`, and the shared `before_sleep_cmd` locks ahead of the suspend.
|
||||||
|
The lid therefore lands at a locked screen, satisfying the criterion by outcome even though the trigger is logind's.
|
||||||
|
|
||||||
|
- **`Super+X` is self-contained.**
|
||||||
|
The keybind execs a guarded hyprlock launch directly (`pidof hyprlock || hyprlock`) rather than `loginctl lock-session`, so the lock key works whenever hyprlock is enabled, without depending on hypridle being the running lock handler.
|
||||||
|
hypridle's own idle and suspend paths still funnel through `loginctl lock-session` so logind tracks the locked state on those paths.
|
||||||
|
|
||||||
|
- **Idle-suspend was left out.**
|
||||||
|
The spec enumerates lock-on-idle, screen-off, lock-before-suspend, and lid-close, so hypridle does not itself suspend on idle.
|
||||||
|
`before_sleep_cmd` handles lock-before-suspend for the lid and any manual or externally configured suspend.
|
||||||
|
Adding an idle-suspend stage is a reasonable future knob but was not requested here.
|
||||||
|
|
||||||
|
- **One hyprlock package.**
|
||||||
|
Both the keybind and hypridle's `lock_cmd` reference `programs.hyprlock.package`, so the locker never splits versions between the two call sites.
|
||||||
32
.claude/tasks/0027-cliphist-clipboard-history.md
Normal file
32
.claude/tasks/0027-cliphist-clipboard-history.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
spec: hyprland-desktop
|
||||||
|
blocked-by: 0024-rofi-launcher
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Clipboard history picked entirely by keyboard.
|
||||||
|
|
||||||
|
Add a cliphist module (with wl-clipboard) to the desktop group, enabled through the aggregator, storing both text and image history and picked through rofi.
|
||||||
|
Bind the picker on `Super+Shift+V`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A cliphist module (with wl-clipboard) exists in the desktop group and is enabled by the aggregator.
|
||||||
|
- [x] Text and image copies are recorded to history.
|
||||||
|
- [x] The history is picked through rofi and bound on `Super+Shift+V`.
|
||||||
|
- [x] neogaia builds green under `nix flake check`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- The module is named `clipboard`, not `cliphist`: cliphist is the tool it is built on, but the option a host enables names the capability.
|
||||||
|
- The two clipboard watchers are not hand-written: home-manager's `services.cliphist` module runs them as a pair of systemd user services, one for text and one for `--type image`.
|
||||||
|
`allowImages` defaults true, so enabling the service alone records both kinds.
|
||||||
|
- That module installs only `cliphist` on PATH and reaches `wl-clipboard` by store path, so `wl-copy`/`wl-paste` are added to `home.packages` here.
|
||||||
|
The spec asks for the module "with wl-clipboard", and a keyboard-driven session wants the two commands for piping to and from the clipboard.
|
||||||
|
- The services bind to `graphical-session.target`, which uwsm starts, matching how mako and hypridle attach to the session on this host.
|
||||||
|
No `systemdTargets` override is needed, since the module's default already resolves to that target.
|
||||||
|
- The picker is a small shell script over the same themed rofi the launcher uses (`cliphist list | rofi -dmenu | cliphist decode | wl-copy`), so history looks like every other menu.
|
||||||
|
Its keybind lives in this module rather than in `hyprland.nix`, contributed through `settings.bind`, keeping the command next to its binding and referenced by store path.
|
||||||
|
- No Waybar `window-rewrite` icon mapping was added: the watchers are headless daemons and the picker is a rofi layer surface, so nothing new ever appears as a tiled window on the workspace indicator the convention governs.
|
||||||
|
- Image entries render as a `[[ binary data … ]]` placeholder line in the rofi list rather than a thumbnail, but selecting one still decodes and re-copies the real image, so both kinds are retrievable.
|
||||||
35
.claude/tasks/0028-screenshot-capture.md
Normal file
35
.claude/tasks/0028-screenshot-capture.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
spec: hyprland-desktop
|
||||||
|
blocked-by: 0021-desktop-group-and-hyprland-session
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Keyboard-driven screenshots that open in an annotation editor by default and land in both the clipboard and a file.
|
||||||
|
|
||||||
|
Add a screenshot module to the desktop group, enabled through the aggregator, using grim and slurp wrapped by grimblast and routed through the satty annotation editor so annotation is the default.
|
||||||
|
Cover region, active-window, and full-screen captures, each exporting to both the clipboard and a file.
|
||||||
|
Bind region on `Super+L`, active window on `Super+Shift+L`, and full screen on `Super+Ctrl+L`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A screenshot module exists in the desktop group and is enabled by the aggregator.
|
||||||
|
- [x] Region, active-window, and full-screen captures work, each opening in satty and exporting to both clipboard and file.
|
||||||
|
- [-] Captures are bound on `Super+L`, `Super+Shift+L`, and `Super+Ctrl+L`.
|
||||||
|
- [x] neogaia builds green under `nix flake check`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Screenshot binds moved off `Super+L` to the Print key family.**
|
||||||
|
Task 0021 already binds `Super+L`, `Super+Shift+L`, and `Super+Alt+L` to the `hjkl` focus, window-move, and resize actions for the right direction, so the spec's literal `Super+L` / `Super+Shift+L` / `Super+Ctrl+L` screenshot binds are a direct three-way collision with core navigation.
|
||||||
|
Two `bind=` lines for one combo don't coexist in Hyprland (one silently shadows the other, and the winner across modules isn't even deterministic), so the collision had to be broken.
|
||||||
|
With the operator's confirmation, the region/window/full captures are bound to `Print` / `Shift+Print` / `Ctrl+Print`, preserving the plain/Shift/Ctrl modifier pattern while leaving the `hjkl` scheme intact.
|
||||||
|
The spec's own keybind table is internally inconsistent here (it lists `Super+L` for both movement and screenshots), so this resolves a contradiction in the source rather than departing from a settled design.
|
||||||
|
|
||||||
|
- **Capture pipeline.**
|
||||||
|
`grimblast save <area|active|screen> -` captures to stdout and pipes into satty, whose copy action is configured with `--copy-command wl-copy --save-after-copy`, so one confirmation lands the shot in both the clipboard and a dated file under `~/Pictures/Screenshots`.
|
||||||
|
`--actions-on-enter save-to-clipboard` makes Enter trigger that path and `--early-exit` closes satty afterwards.
|
||||||
|
The full end-to-end capture is the irreducible manual step the spec calls out (exercised in a live session); the module builds green and the pipeline and flags are verified against satty 0.21.1.
|
||||||
|
|
||||||
|
- **No waybar icon for satty.**
|
||||||
|
The repo convention adds a `window-rewrite` mapping for each graphical application, but satty is a transient floating annotation window rather than a window that lives on a workspace, so at the operator's direction it gets no workspace glyph.
|
||||||
42
.claude/tasks/0029-screen-recording.md
Normal file
42
.claude/tasks/0029-screen-recording.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
spec: hyprland-desktop
|
||||||
|
blocked-by: [0023-waybar-status-bar, 0025-mako-notifications]
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
A keybound screen recorder that selects a region and then toggles recording.
|
||||||
|
|
||||||
|
Add a wf-recorder module to the desktop group, enabled through the aggregator, that selects a region first and then toggles video-only recording (no audio), bound on `Super+Shift+R`.
|
||||||
|
Surface a recording indicator in the Waybar bar and notification toasts on start and stop.
|
||||||
|
No audio capture and no full-screen recording variant.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A wf-recorder module exists in the desktop group and is enabled by the aggregator.
|
||||||
|
- [x] The recorder selects a region first, then toggles video-only recording on `Super+Shift+R`.
|
||||||
|
- [x] A recording indicator appears in the bar, and notifications fire on start and stop.
|
||||||
|
- [x] No audio is captured and no full-screen variant is provided.
|
||||||
|
- [x] neogaia builds green under `nix flake check`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Toggle design.**
|
||||||
|
One key both starts and stops.
|
||||||
|
A running capture is stopped with SIGINT so wf-recorder finalises the file; otherwise slurp picks a region and wf-recorder runs in the foreground for the whole recording, so the same invocation fires the "saved" notification once the file is written.
|
||||||
|
Region-first and video-only (no `-a`, so no audio) satisfy the spec directly, and no full-screen variant is offered.
|
||||||
|
|
||||||
|
- **Bar indicator polls rather than signals.**
|
||||||
|
The Waybar `custom/recording` widget samples the wf-recorder process with `pgrep` on a one-second interval, showing a video glyph while a capture runs and collapsing to nothing when idle.
|
||||||
|
An earlier draft signalled Waybar (`pkill -RTMIN+9`) from the toggle, but the start path raised the signal before wf-recorder had launched, so `pgrep` saw nothing and the indicator never lit during a recording — caught in review.
|
||||||
|
Polling is race-free, removes the signal number shared across two files, and is adequate for a status glyph.
|
||||||
|
|
||||||
|
- **No waybar `window-rewrite` icon.**
|
||||||
|
wf-recorder is headless and slurp is a transient selection overlay, so neither owns a workspace window and the per-application icon convention does not apply.
|
||||||
|
|
||||||
|
- **Output paths follow XDG user-dirs.**
|
||||||
|
A new `modules.desktop.userdirs` declares the XDG user directories (home-manager `xdg.userDirs`), and the recorder resolves its base with `xdg-user-dir VIDEOS`, writing timestamped `recording-<date>.mp4` under `<Videos>/Recordings` (created on first capture).
|
||||||
|
The screenshot module (task 0028) was aligned to the same convention (`xdg-user-dir PICTURES` → `<Pictures>/Screenshots`), so relocating a directory is a one-line change to `xdg.userDirs` rather than an edit in each tool.
|
||||||
|
|
||||||
|
- **Live capture is the irreducible manual step.**
|
||||||
|
The build is green, the config parses under `Hyprland --verify-config`, and the indicator's idle/recording transitions are verified against a stand-in process; exercising a real slurp selection and wf-recorder capture needs a running session.
|
||||||
35
.claude/tasks/0030-desktop-portals.md
Normal file
35
.claude/tasks/0030-desktop-portals.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
spec: hyprland-desktop
|
||||||
|
blocked-by: 0021-desktop-group-and-hyprland-session
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Screen sharing that works inside applications, so video calls and browser screen-share function.
|
||||||
|
|
||||||
|
Add a portals module to the desktop group, enabled through the aggregator, wiring the Hyprland desktop portal (screencast, screenshot, global shortcuts) plus the GTK portal (file dialogs and appearance).
|
||||||
|
In-app screen sharing depends on these regardless of whether the recorder is present.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A portals module exists in the desktop group and is enabled by the aggregator.
|
||||||
|
- [x] The Hyprland desktop portal (screencast, screenshot, global shortcuts) and the GTK portal (file dialogs, appearance) are both configured.
|
||||||
|
- [x] In-app screen sharing is available independent of the screen recorder.
|
||||||
|
- [x] neogaia builds green under `nix flake check`.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **The module owns routing, not the backend packages.**
|
||||||
|
The Hyprland compositor integration (`programs.hyprland`) already forces both portal backends into `xdg.portal.extraPortals` — `xdg-desktop-portal-hyprland` through its `portalPackage`, and `xdg-desktop-portal-gtk` through nixpkgs' `wayland-session.nix` (`enableGtkPortal` defaults on) — and turns `xdg.portal.enable` on.
|
||||||
|
A portal backend only answers while its compositor runs, so those packages belong with the compositor and cannot be removed there; re-declaring them here would only duplicate them.
|
||||||
|
The genuinely-missing, first-class piece was the routing: `xdg.portal.config` was empty, and which backend answered each request rode on a config file the Hyprland package happens to ship (`hyprland-portals.conf`, `default=hyprland;gtk`).
|
||||||
|
This module makes that routing explicit and declarative.
|
||||||
|
|
||||||
|
- **Per-interface routing, not a preference list.**
|
||||||
|
Rather than `default = [ "hyprland" "gtk" ]` (which tries Hyprland first for every interface and falls through to GTK), the three interfaces the Hyprland portal actually implements — `ScreenCast`, `Screenshot`, `GlobalShortcuts`, confirmed from its `hyprland.portal` file — are routed to Hyprland explicitly, and GTK is the default for everything else.
|
||||||
|
This directly encodes the spec's split (Hyprland for the screen-facing requests, GTK for file dialogs and appearance) and keeps appearance on GTK even if a future Hyprland portal starts implementing `org.freedesktop.impl.portal.Settings`.
|
||||||
|
|
||||||
|
- **Already functional, now first-class.**
|
||||||
|
Because the compositor integration already supplied both backends and a working shipped route, in-app screen sharing was effectively working before this task as an implicit side-effect.
|
||||||
|
The deliverable is the explicit, aggregator-enabled `modules.desktop.portals` module, so the desktop's checklist reads completely and screen sharing no longer depends on a package's incidental default.
|
||||||
|
Verified: the built config emits `/etc/xdg/xdg-desktop-portal/portals.conf` with `default=gtk` plus the three Hyprland routes, and neogaia's toplevel builds green.
|
||||||
29
.claude/tasks/0031-module-namespace-tidy.md
Normal file
29
.claude/tasks/0031-module-namespace-tidy.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
## What to build
|
||||||
|
|
||||||
|
Tidy the module tree so a Module's option namespace mirrors its directory under `modules/`, adopt that as a documented convention, group the agent-related Modules under a new `agents/` directory, bring `desktop/hyprland/` into conformance, and drop the obsolete reference Module.
|
||||||
|
|
||||||
|
The convention: a Module's option path mirrors its directory path, and a file whose name matches its directory is that directory's index node — it declares the directory's own segment (its `enable`/aggregator) rather than a doubled segment. A file `foo.nix` in directory `d/` declares `modules.<…>.d.foo`. A group directory with no matching index file contributes a namespace segment but no aggregate `enable`.
|
||||||
|
|
||||||
|
Applying it:
|
||||||
|
|
||||||
|
- **Agents grouping.** Relocate the agent Modules under `modules.agents.*`: `claude-code` (its whole directory, assets included) → `modules.agents.claude-code`; `pi` flattened from its directory to a single file → `modules.agents.pi`; the skills Module renamed from `agent-skills` → `agents/skills.nix`; and `gitea-axi` into an `agents/tools/` subgroup → `modules.agents.tools.gitea-axi`. `tools/` is a real namespace segment, not a cosmetic folder.
|
||||||
|
- **No aggregators.** `agents/` and `tools/` are pure namespace prefixes — no `modules.agents.enable` or `modules.agents.tools.enable`. Agents are enabled à la carte.
|
||||||
|
- **Skills stays enable-less.** The skills Module keeps its current behaviour (unconditionally wires `programs.agents.skills`, empty list); it is the one deliberate exception to the Enable convention, marked as intentional by a self-contained comment in the file.
|
||||||
|
- **Desktop conformance.** Nest `hypridle` and `hyprlock` under `modules.desktop.hyprland.*` (matching the index-file rule, `hyprland.nix` being the index), and update `desktop.nix`'s aggregator to the new paths. The 13 flat `desktop/*.nix` Modules keep their `modules.desktop.<name>` names — broader semantic regrouping is explicitly out of scope for this task.
|
||||||
|
- **Remove the example Module.** Delete `modules/example.nix`; the documented convention and the many real Modules supersede its teaching role.
|
||||||
|
|
||||||
|
Also update the one Host that carries these Modules and the live documentation, and record the convention in the domain model.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] `CONTEXT.md` gains a `Namespace convention` glossary entry stating the directory-mirrors-namespace rule and the index-file rule, in glossary style (no implementation detail).
|
||||||
|
- [x] An ADR (next number: `0004`) records the decision — nested-mirrors-directory over flat names, a subfolder as a real namespace segment, the index-file rule, and no `agents` aggregator — following the ADR format.
|
||||||
|
- [x] Agent Modules resolve under `modules.agents.*`: `modules.agents.claude-code.enable`, `modules.agents.pi.enable`, and `modules.agents.tools.gitea-axi.enable` exist; `modules.claude-code`, `modules.pi`, and `modules.gitea-axi` no longer resolve.
|
||||||
|
- [x] The skills Module lives at `agents/skills.nix` (renamed from `agent-skills.nix`), stays enable-less, still wires `programs.agents.skills`, and carries an in-file comment marking the Enable-convention exception as intentional.
|
||||||
|
- [x] Neither `modules.agents.enable` nor `modules.agents.tools.enable` exists (pure namespace prefixes, no aggregator).
|
||||||
|
- [x] `claude-code`'s assets (`CLAUDE.md`, `authentication.md`, `hooks/`, `skills/`) travel with the move and its relative references still resolve.
|
||||||
|
- [x] `desktop/hyprland/`: `modules.desktop.hyprland.hypridle` and `modules.desktop.hyprland.hyprlock` resolve; the old `modules.desktop.hypridle`/`modules.desktop.hyprlock` no longer exist; `desktop.nix` enables the new paths; `modules.desktop.enable` still brings up the whole session.
|
||||||
|
- [x] `modules/example.nix` is removed and `modules.example` no longer resolves.
|
||||||
|
- [x] `hosts/neogaia/default.nix` uses the new option paths for claude-code, pi, and gitea-axi.
|
||||||
|
- [x] The two live `CLAUDE.md` gotchas — the `gitea-axi` install line and the `claude-code` skill-source path — are updated to the new option/path; `.claude/tasks/*` are left unchanged as historical record.
|
||||||
|
- [x] `nix flake check` builds `checks.x86_64-linux.neogaia` green (moved files staged so evaluation sees them).
|
||||||
60
.claude/tasks/0032-firefox-browser.md
Normal file
60
.claude/tasks/0032-firefox-browser.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
spec: firefox
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to build
|
||||||
|
|
||||||
|
Add Firefox as a new single-purpose Module under the desktop group, configured entirely through the primary user's home-manager `programs.firefox`, and fold it into the desktop aggregator so the browser comes up as part of the daily-drivable session on any Host that enables the desktop.
|
||||||
|
|
||||||
|
The browser is stock mainline Firefox, hardened and de-monetized through locked enterprise policies, carrying a small fixed set of extensions force-installed by policy from Mozilla's add-on site.
|
||||||
|
Search defaults to DuckDuckGo over a lean, pruned engine list.
|
||||||
|
The browser is themed Nord from the same single Stylix source as the rest of the graphical layer, and registered as the system default handler for web links.
|
||||||
|
Bookmarks and container tabs are deliberately not declared, leaving that state to the browser's own runtime management.
|
||||||
|
|
||||||
|
The end-to-end result: a Host with the desktop enabled boots into a session where the browser is present, launchable, themed to match, telemetry-quiet, has its extensions installed and enabled on first launch, uses DuckDuckGo, and receives links opened from other applications.
|
||||||
|
|
||||||
|
Scope details, all following the domain conventions (Namespace convention, Enable convention, aggregator fan-out):
|
||||||
|
|
||||||
|
- **Module and placement.** One `enable` option namespaced to mirror the file's location under the desktop group, guarded by the Enable convention. Configured only through home-manager `programs.firefox`; no NixOS-level Firefox program integration and no manual package override. The desktop aggregator turns it on at default priority alongside the terminal, so the single desktop flag brings it up while a Host can still override it.
|
||||||
|
- **Package and extensions.** Stock mainline Firefox, not ESR/unbranded/Developer Edition. The three extensions — an ad and content blocker, the operator's password manager, and a video sponsor-skipper — are installed through the enterprise `force_installed` policy keyed by add-on id with an install URL, so Firefox fetches the signed add-on and enables it automatically. No Nix-built or hash-pinned add-on packages, and no native messaging host.
|
||||||
|
- **Hardening.** Split by intent: policy-backed items are set as locked policies (telemetry, studies, and data reporting off; read-it-later widget off; offer-to-save-logins off; default-browser check off; sponsored shortcuts, stories, and snippets stripped from the new-tab page; Firefox accounts and sync disabled), and the rest as ordinary profile preferences (sponsored address-bar suggestions off, new-tab surface tidied). Fingerprinting resistance stays off.
|
||||||
|
- **Search.** A single profile, named the default. DuckDuckGo as the default engine, the engine list pruned to a lean set with the general-purpose commercial engines removed, using the module's authoritative-overwrite acknowledgement.
|
||||||
|
- **Theming.** Enable the Stylix Firefox target against the declared profile, driven from the shared Nord scheme, set from within this Module (mirroring how the theming Module already sets per-Module Stylix targets). No hand-written browser chrome CSS.
|
||||||
|
- **Default browser.** Register Firefox as the default handler for the web-link schemes and HTML through the user's home-manager mime-association config, placed in this Module.
|
||||||
|
|
||||||
|
Record the pivotal, hard-to-reverse decision — stock Firefox plus policy-installed extensions over an ESR/unbranded build with hash-pinned add-on packages — as an ADR, following the ADR format and the next ADR number.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] A new Firefox Module exists under the desktop group with a single `enable` option, namespaced to mirror its directory per the Namespace convention and guarded per the Enable convention; `modules.desktop.firefox.enable` resolves.
|
||||||
|
- [x] The Module is configured only through the primary user's home-manager `programs.firefox`; there is no NixOS-level Firefox program integration and no manual package override.
|
||||||
|
- [x] The desktop aggregator enables the Module at default priority, so `modules.desktop.enable` brings the browser up and a Host can still override the single flag; neogaia carries it through the desktop flag with no per-Host browser line.
|
||||||
|
- [x] The package is stock mainline Firefox (not ESR, unbranded, or Developer Edition).
|
||||||
|
- [x] The three extensions are force-installed via enterprise policy keyed by add-on id with an install URL: the ad and content blocker, the password manager, and the video sponsor-skipper; no native messaging host is declared.
|
||||||
|
- [x] Locked policies turn off telemetry, studies, and data reporting; turn off the read-it-later widget; stop offer-to-save-logins; stop the default-browser check; strip sponsored shortcuts, stories, and snippets from the new-tab page; and disable Firefox accounts and sync.
|
||||||
|
- [x] Profile preferences turn off sponsored address-bar suggestions and tidy the new-tab surface; fingerprinting resistance is left off.
|
||||||
|
- [x] A single default profile is declared with DuckDuckGo as the default search engine and the engine list pruned to a lean set (general-purpose commercial engines removed), using the search authoritative-overwrite acknowledgement.
|
||||||
|
- [x] The Stylix Firefox target is enabled against the declared profile from within this Module, driven from the shared Nord scheme; no hand-written browser chrome CSS is shipped.
|
||||||
|
- [x] Firefox is registered as the default handler for the web-link schemes and HTML through the user's home-manager mime-association config.
|
||||||
|
- [x] An ADR (next number) records the stock-Firefox-plus-policy-extensions decision over an ESR/unbranded build with hash-pinned add-on packages, following the ADR format.
|
||||||
|
- [x] `nix flake check` builds `checks.x86_64-linux.neogaia` green, with the new file staged so evaluation sees it.
|
||||||
|
- [-] Manual confirmation on neogaia: after switching, the browser launches with the three extensions present and enabled, the Nord theme applied, DuckDuckGo as default search, and a link opened from another application lands in it.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
The module lives at `modules/desktop/firefox.nix`, declares `modules.desktop.firefox.enable`, and is fanned out by the desktop aggregator at `lib.mkDefault true`.
|
||||||
|
Everything is configured through `home-manager.users.<user>.programs.firefox`, with no NixOS-level program integration and no `package` override, so it stays on stock `pkgs.firefox` (built as `firefox-152.0.6`, the mainline release train).
|
||||||
|
|
||||||
|
The three force-installed extensions are keyed by their real add-on ids, verified against Mozilla's AMO API rather than guessed: uBlock Origin (`uBlock0@raymondhill.net`), Proton Pass (`78272b6fa58f4a1abaac99321d503a20@proton.me`), and SponsorBlock (`sponsorBlocker@ajay.app`).
|
||||||
|
Proton Pass is the operator's password manager, per ADR 0002 and the sops spec.
|
||||||
|
|
||||||
|
Search pruning deviated from a first pass that merely omitted the commercial engines.
|
||||||
|
Omission does not remove them: home-manager's search module writes `search.json.mozlz4`, but Firefox reconciles its locale's app-provided engines back in for any not present in the file, so the general-purpose commercial engines would reappear.
|
||||||
|
The lean set is instead reached by explicitly hiding them with `<engine>.metaData.hidden = true` (the module's documented builtin-hiding idiom), confirmed by decoding the built `search.json.mozlz4`: it carries `_metaData.hidden` on google, bing, ebay, and amazon, with `defaultEngineId = "ddg"`.
|
||||||
|
DuckDuckGo and Wikipedia remain visible; the hidden engines stay reachable through DuckDuckGo bangs.
|
||||||
|
Engines are referenced by their current id form (`ddg`, `google`, …), which the module maps from the old display names — `default = "ddg"` is correct, not `"DuckDuckGo"`.
|
||||||
|
|
||||||
|
The decision to ship stock Firefox with policy-installed extensions over an ESR/unbranded build with hash-pinned add-ons is recorded as ADR 0005.
|
||||||
|
|
||||||
|
The final acceptance criterion is marked `[-]` rather than `[x]`: it is the irreducible manual confirmation the spec calls out (a browser cannot self-test headless), deferred to the operator on the live machine after switching, not dropped work.
|
||||||
|
Every automatable check — the whole-Host toplevel build, and eval probes for the aggregator fan-out, the three force-installed ids, the DuckDuckGo default, the hidden commercial engines, the Stylix Firefox target, and the mime handlers — passes.
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,8 +1 @@
|
|||||||
/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,7 +7,6 @@ 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.
|
||||||
@@ -19,16 +18,9 @@ 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
94
AGENTS.md
@@ -1,94 +0,0 @@
|
|||||||
# 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.
|
|
||||||
114
CLAUDE.md
114
CLAUDE.md
@@ -1,5 +1,111 @@
|
|||||||
# Claude Code compatibility
|
# dotfiles-nixos
|
||||||
|
|
||||||
You MUST read and follow [`AGENTS.md`](AGENTS.md) before doing any work in this repository.
|
One flake that builds every machine the user owns.
|
||||||
`AGENTS.md` is the canonical project instruction file.
|
The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overlays) lives in `.claude/CONTEXT.md`; the current deliverable's spec is `.claude/spec/laptop-mvi.md`.
|
||||||
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.
|
||||||
|
- The substituters a `nix build` fetches from are the **daemon's** (`/etc/nix/nix.conf`), *not* the `nix.settings` of the config being built — those only govern the built system.
|
||||||
|
The two coincide here because the dev host runs this flake; they diverge on any machine that does not.
|
||||||
|
- 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.
|
||||||
|
Home-manager writes `~/.config/git/config`, and `~/.gitconfig` is a second global file that git also reads, outranking it on any key set in both.
|
||||||
|
`~/.gitconfig` 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.
|
||||||
|
- `git config --global` is a listing and writing filter over `~/.gitconfig` alone, **not** a view of what git resolves.
|
||||||
|
With both global files present it prints only `~/.gitconfig`, which reads as proof that `~/.config/git/config` is being ignored entirely.
|
||||||
|
It is not: drop `--global` and both files appear, each key resolving to the last file that sets it.
|
||||||
|
A `git config --global <key> <value>` write also lands in `~/.gitconfig`, the file that outranks the flake-managed one.
|
||||||
|
- 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...`.
|
||||||
|
- A flake only sees **git-tracked** files, so a new file that has not been `git add`ed is invisible to evaluation even though it exists on disk.
|
||||||
|
The failure names the path and reads as if the file were missing: `error: Path 'secrets/shared.yaml' does not exist in Git repository`.
|
||||||
|
Staging is enough; the file need not be committed.
|
||||||
|
- 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.
|
||||||
|
- SSH **host** keys (`ssh_host_<type>_key`, served by the daemon from `/etc/ssh` or a secret) are not user authentication keys (`~/.ssh/id_ed25519`, offered to a remote server).
|
||||||
|
The `ssh_host_` prefix is OpenSSH's own name for the former, and the `root@<host>` trailing field in a `.pub` is a free-text comment stamped by `ssh-keygen` at generation time, not a claim about which account uses the key.
|
||||||
|
On this machine the two are provably distinct: the daemon presents `SHA256:2ysuBX0+Z6GbdCTujz5JHX6rqnJzIyWhYNrxdhhGwEM`, while pushes to `git.alexion.dev` authenticate with `SHA256:nEhHwtHDnLlsuFxyfp+cETgHUZ8xDMxaPVmYM5vuCkA`.
|
||||||
|
Renaming host keys after user keys, or vice versa, is therefore always wrong.
|
||||||
|
- `~/.claude/skills` is generated by home-manager with `recursive = true`, so the directories are real and writable but every leaf file is a read-only symlink into the store.
|
||||||
|
Editing a skill in place fails; its source is `modules/agents/claude-code/skills/<name>/` here, applied by a rebuild.
|
||||||
|
Creating a new file under `~/.claude/skills/` succeeds silently and is the trap — it stays outside the repo and reaches no other machine.
|
||||||
|
Copying out of that tree needs `cp -rL` plus `chmod -R u+w`: a plain `cp -r` copies the symlinks, putting store paths into the destination, and dereferenced files keep the store's read-only mode.
|
||||||
|
- `home-manager.users.<user>.home.file` is keyed by whatever path string the **defining module wrote**, absolute or relative, not by one canonical form.
|
||||||
|
A module that writes `home.file."/home/alexion/.claude/CLAUDE.md"` is reachable only at that absolute key, while `programs.firefox` writes relative keys such as `home.file.".config/mozilla/firefox/profiles.ini"` reachable only at the relative form.
|
||||||
|
The other form fails with "does not provide attribute", so overriding an entry (e.g. setting `.force = true` on it) requires matching the writer's exact key.
|
||||||
|
List the real keys with `nix eval --json .#nixosConfigurations.<host>.config.home-manager.users.<user>.home.file --apply builtins.attrNames` rather than guessing one.
|
||||||
|
A key's `.source` is the input file, whose store path differs from the deployed symlink's target (home-manager copies it to a `hm_`-prefixed path) even though the contents match.
|
||||||
|
- 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`.
|
||||||
|
- nixpkgs `vimPlugins.nvim-treesitter` tracks the rewritten `main` branch: there is no `require("nvim-treesitter.configs").setup{ensure_installed,highlight,indent}`. Under nixvim, use `plugins.treesitter` with `highlight.enable`/`indent.enable` and `grammarPackages = with config.programs.nixvim.plugins.treesitter.package.builtGrammars; [ ... ]` — the module's own `package.builtGrammars`, **not** `pkgs.vimPlugins.nvim-treesitter.*` (whose query files can mismatch). The module targets the main branch and enables features via neovim-native APIs (`vim.treesitter.start()`, `require'nvim-treesitter'.indentexpr()`).
|
||||||
|
- Neovim is configured via **nixvim** (flake input `nixvim`, consumed as `inputs.nixvim.homeModules.nixvim` added to `home-manager.sharedModules`, config under `home-manager.users.<user>.programs.nixvim`). `nixvim.inputs.nixpkgs.follows = "nixpkgs"` is set; nixvim then emits a benign eval warning that its pinned nixpkgs differs from the followed one — builds and runs fine, do not "fix" it by dropping the follows.
|
||||||
|
- To reference the nixvim-built package's own attrs (e.g. treesitter `builtGrammars`) inside our NixOS module, give `home-manager.users.<user>` the module-function form (`hm: { programs.nixvim = { ... hm.config.programs.nixvim... }; }`), since the outer `config` is the NixOS config, not the home-manager one.
|
||||||
|
- The agent's Bash sandbox blocks `sudo` and swallows it into a bare exit 1 with **no stderr**, which looks identical to the command itself failing.
|
||||||
|
Re-run with the sandbox disabled to see the real error (`sudo: a password is required`) before diagnosing anything else.
|
||||||
|
Separately, `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.
|
||||||
|
- Sudo's credential cache is keyed per user rather than per terminal (`timestamp_type=global`, 60-minute window, declared by the claude-code module), so an authentication made in one terminal counts for commands the agent runs.
|
||||||
|
Warming it with `sudo -v` through the agent's own shell — including the `!` prefix — never works: that shell has no controlling terminal, and sudo reports `a terminal is required to read the password`.
|
||||||
|
It has to be a separate terminal.
|
||||||
|
A `PreToolUse` hook refuses privileged commands while the cache is cold, so a cold cache announces itself instead of stalling; a failure *without* that message is the sandbox, not the cache.
|
||||||
|
- An `mkOption` of a list or attribute-set type is **not** mandatory the way a scalar one is.
|
||||||
|
Those types carry an `emptyValue`, so an option declared with no `default` and never set evaluates to `[ ]` or `{ }` instead of failing with "option used but not defined".
|
||||||
|
A declaration that is genuinely required cannot be expressed by omitting the default — it needs an assertion, or a default chosen so that the silent case is the safe one.
|
||||||
|
This bites hardest where the empty value is itself dangerous, such as a list of authorized SSH keys, where it means a machine nobody can reach.
|
||||||
|
- `home-manager.users.<user>` cannot be assigned twice at the same level in one module: `home-manager.users.${user}.home.packages` alongside `home-manager.users.${user}.programs.x` fails with `error: dynamic attribute 'alexion' already defined`.
|
||||||
|
The interpolated key makes it a dynamic attribute, which nix will not merge the way it merges static paths.
|
||||||
|
Nest both under a single `home-manager.users.${user} = { ... }`.
|
||||||
|
- **Verifying a nixvim change headless:** `programs.nixvim.build.package`'s wrapper has **no `-u`**, so running `$OUT/bin/nvim` loads the caller's `~/.config/nvim` (the dev host's real config), *not* the built config — silently. To exercise the built config, launch with `-u "$(nix build --no-link --print-out-paths .#…programs.nixvim.build.initFile)"` and a scratch `HOME`/`XDG_CONFIG_HOME`. `conceallevel` is window-local: set it with `opt_local`/`vim.wo`, never `vim.bo[buf]` (which errors).
|
||||||
|
- Host GPUs: `neogaia` is Intel and `zeus` (the desktop) is **AMD**.
|
||||||
|
`raichu`, a server with no desktop, is the only Nvidia machine.
|
||||||
|
`laptop-mvi.md`'s out-of-scope line calls zeus Nvidia, but that is stale and the document is kept historical and unchanged, so do not infer any host's GPU from it.
|
||||||
|
The corrected fact lives in ADR 0003 and the `hyprland-desktop` spec.
|
||||||
|
- The home-manager `wayland.windowManager.hyprland` module defaults `configType` to `"lua"` at `home.stateVersion` >= 26.05, writing `hyprland.lua` through an `hl.*` Lua API instead of the native `hyprland.conf`.
|
||||||
|
The Lua backend mangles `$mod`-style variables and INI `bind=` strings into invalid Lua (`hl.$mod("SUPER")`), and does not fail the build, since the config is only text.
|
||||||
|
Set `configType = "hyprlang"` to get the native `hyprland.conf` whose variable and bind syntax the usual settings are written in.
|
||||||
|
Render the file to check which format is in effect: `nix build --print-out-paths .#nixosConfigurations.<host>.config.home-manager.users.<user>.xdg.configFile.\"hypr/hyprland.conf\".source` (only the enabled `configType`'s key exists).
|
||||||
|
- An invalid Hyprland dispatcher or config-option name never fails the nix build, since `hyprland.conf` is only text, so it surfaces only when the compositor loads the file at login.
|
||||||
|
The build/render check is therefore blind to it, and the real test is a running session (or reading `~/.config/hypr/hyprland.conf` against the running package's own names).
|
||||||
|
Two that bit on 0.55.4: the dwindle split actions `togglesplit`, `swapsplit`, and `pseudo` are layout messages reached through the `layoutmsg` dispatcher (`bind = $mod, T, layoutmsg, togglesplit`), not top-level dispatchers, and the old `dwindle:pseudotile` option is gone.
|
||||||
|
Confirm names against the pinned package rather than the wiki, whose "latest" drifts from it.
|
||||||
|
The config can in fact be checked offline: `Hyprland --verify-config -c <rendered-conf>` parses the file and prints `config ok` or the exact `line N:` error without a running compositor, so a rule change is provable before login rather than only at it.
|
||||||
|
- Hyprland 0.55.4 uses windowrule v3 syntax, which is not the `windowrule = float, class:^(re)$` form the wiki still shows.
|
||||||
|
A flat `windowrule =` entry is a comma-separated list of `field value` tokens, each of which **must** carry a value: matchers take a `match:` prefix and effects are bare, so floating one app is `windowrule = float 1, match:class ^(com\.gabm\.satty)$`.
|
||||||
|
The old form fails at load with `invalid field float: missing a value`, because the effect token has no value.
|
||||||
|
`windowrulev2` is removed and errors as deprecated.
|
||||||
|
- A multi-path `git add a b c` aborts entirely and stages **nothing** when any one pathspec matches no file, so a stale path in the list silently drops every other file from the commit.
|
||||||
|
This bit here: a path already removed by `git rm` was passed to a later `git add`, which failed with `fatal: pathspec ... did not match any files` and staged none of the real edits beside it, landing a commit that moved files but kept the old option paths.
|
||||||
|
Stage in separate `git add` calls, or `git status` the result before committing rather than trusting the add.
|
||||||
|
- A `nix build` or `nix flake check` on a **dirty** tree evaluates the working-copy content of tracked files, not what is committed, and only warns `Git tree ... is dirty`.
|
||||||
|
A green check on a dirty tree therefore proves nothing about the commit.
|
||||||
|
To verify a commit, build once on a clean tree (nothing uncommitted), where the absence of the dirty warning confirms the build reflects `HEAD`.
|
||||||
|
- home-manager's `programs.firefox` declarative `search` with `force = true` does **not** prune Firefox's built-in engines by omission.
|
||||||
|
The overwrite writes `search.json.mozlz4` with only the engines listed, but Firefox reconciles its locale's app-provided engines back in for any not present in the file, so Google, Bing, and the rest reappear.
|
||||||
|
To actually remove a builtin, list it explicitly with `<engine>.metaData.hidden = true` — an engine entry carrying only `metaData` is treated as a builtin rather than a custom engine.
|
||||||
|
Engines are referenced by their current id, which the module maps from the old display names, so the default is `default = "ddg"`, not `"DuckDuckGo"` (the latter only warns and migrates).
|
||||||
|
Prove the result by decoding the built file: `mozlz4a -d <search.json.mozlz4>` shows the `_metaData.hidden` flags and `defaultEngineId`.
|
||||||
|
|||||||
91
base.nix
91
base.nix
@@ -1,91 +0,0 @@
|
|||||||
{
|
|
||||||
config,
|
|
||||||
lib,
|
|
||||||
inputs,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
# The shared foundation both the host base and the guest-base build on: the
|
|
||||||
# primary user, home-manager, and the fresher/pinned package overlays.
|
|
||||||
let
|
|
||||||
inherit (lib) mkOption types;
|
|
||||||
user = config.user;
|
|
||||||
|
|
||||||
# Args to instantiate an extra nixpkgs source on the base platform.
|
|
||||||
pinArgs = prev: {
|
|
||||||
inherit (prev.stdenv.hostPlatform) system;
|
|
||||||
config.allowUnfree = true;
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
imports = [ inputs.home-manager.nixosModules.home-manager ];
|
|
||||||
|
|
||||||
options.user = {
|
|
||||||
name = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "alexion";
|
|
||||||
description = ''
|
|
||||||
The primary interactive user this system is built for. Drives both the
|
|
||||||
system account and the home-manager user in lockstep.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
description = mkOption {
|
|
||||||
type = types.str;
|
|
||||||
default = "Alexion";
|
|
||||||
description = "Human-readable description (GECOS field) for the primary user.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = {
|
|
||||||
# Reach fresher packages with `unstable.<name>` or pin with `stable.<name>`.
|
|
||||||
nixpkgs.overlays = [
|
|
||||||
(_final: prev: {
|
|
||||||
unstable = import inputs.nixpkgs-unstable (pinArgs prev);
|
|
||||||
stable = import inputs.nixpkgs-stable (pinArgs prev);
|
|
||||||
})
|
|
||||||
];
|
|
||||||
nixpkgs.config.allowUnfree = true;
|
|
||||||
|
|
||||||
# Flakes, so `nixos-rebuild switch` works from the console and a direnv
|
|
||||||
# `use flake` resolves inside a guest.
|
|
||||||
nix.settings.experimental-features = [
|
|
||||||
"nix-command"
|
|
||||||
"flakes"
|
|
||||||
];
|
|
||||||
|
|
||||||
# Primary user.
|
|
||||||
# The wheel group is the way in, since root is locked.
|
|
||||||
# No password is set here, since that is host-only.
|
|
||||||
# A guest therefore has none and is reached by SSH key or `machinectl`.
|
|
||||||
users.users.${user.name} = {
|
|
||||||
isNormalUser = true;
|
|
||||||
description = user.description;
|
|
||||||
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
|
|
||||||
# environment together, sharing the system's pkgs and installing user
|
|
||||||
# packages into the system profile.
|
|
||||||
home-manager = {
|
|
||||||
useGlobalPkgs = true;
|
|
||||||
useUserPackages = true;
|
|
||||||
extraSpecialArgs = {
|
|
||||||
inherit inputs;
|
|
||||||
my = inputs.self.lib;
|
|
||||||
};
|
|
||||||
users.${user.name} = {
|
|
||||||
home.username = user.name;
|
|
||||||
home.homeDirectory = "/home/${user.name}";
|
|
||||||
home.stateVersion = "26.05";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -103,8 +103,7 @@ $ cd dotfiles
|
|||||||
Do **not** point `disko-install` straight at the Gitea flake URL.
|
Do **not** point `disko-install` straight at the Gitea flake URL.
|
||||||
Gitea serves HTTPS with a self-signed certificate and expects authentication, and Nix's flake fetcher has no easy way to skip certificate verification or supply those credentials mid-install.
|
Gitea serves HTTPS with a self-signed certificate and expects authentication, and Nix's flake fetcher has no easy way to skip certificate verification or supply those credentials mid-install.
|
||||||
A plain `git clone` sidesteps that entirely — over SSH there is no TLS, and over HTTPS git takes the `sslVerify=false` above that the flake fetcher won't — and then `disko-install` consumes the flake from a local path, where no fetch of our repo happens during the build.
|
A plain `git clone` sidesteps that entirely — over SSH there is no TLS, and over HTTPS git takes the `sslVerify=false` above that the flake fetcher won't — and then `disko-install` consumes the flake from a local path, where no fetch of our repo happens during the build.
|
||||||
(Every other flake input is public and still fetched from GitHub over ordinary, valid TLS.
|
(Every other flake input is public and still fetched from GitHub over ordinary, valid TLS; only our own repo is the problem the local clone solves.)
|
||||||
Only our own repo is the problem the local clone solves.)
|
|
||||||
|
|
||||||
### 3. Generate the host identity
|
### 3. Generate the host identity
|
||||||
|
|
||||||
@@ -202,20 +201,17 @@ $ sudo nix --extra-experimental-features 'nix-command flakes' run \
|
|||||||
What each part does:
|
What each part does:
|
||||||
|
|
||||||
- `--flake .#neogaia` installs the `neogaia` `Host` from the local clone.
|
- `--flake .#neogaia` installs the `neogaia` `Host` from the local clone.
|
||||||
- `--disk main /dev/nvme0n1` maps disko's `main` disk to the NVMe device.
|
- `--disk main /dev/nvme0n1` maps disko's `main` disk to the NVMe device; it matches the device declared in `hosts/neogaia/disk.nix` and is stated explicitly so there is no doubt about the target.
|
||||||
It matches the device declared in `hosts/neogaia/disk.nix` and is stated explicitly so there is no doubt about the target.
|
|
||||||
- `--write-efi-boot-entries` writes the systemd-boot entry into this machine's NVRAM, because the disk stays in the machine it was installed from.
|
- `--write-efi-boot-entries` writes the systemd-boot entry into this machine's NVRAM, because the disk stays in the machine it was installed from.
|
||||||
- The two `--option` lines are the important part: they hand the **chaotic binary cache** to the install-time Nix daemon on the live ISO.
|
- The two `--option` lines are the important part: they hand the **chaotic binary cache** to the install-time Nix daemon on the live ISO.
|
||||||
|
|
||||||
The chaotic substituter must be passed here explicitly.
|
The chaotic substituter must be passed here explicitly.
|
||||||
The `nix.settings` in the flake configure the substituters of the *installed* system, not the live ISO's daemon that runs this build.
|
The `nix.settings` in the flake configure the substituters of the *installed* system, not the live ISO's daemon that runs this build; the ISO's daemon has no `substituters` beyond `cache.nixos.org`.
|
||||||
The ISO's daemon has no `substituters` beyond `cache.nixos.org`.
|
|
||||||
Without these two `--option` flags, the build cannot fetch the prebuilt CachyOS kernel and **compiles `linuxPackages_cachyos` (and its toolchain) from source on the USB stick** — a very long detour that the cache avoids.
|
Without these two `--option` flags, the build cannot fetch the prebuilt CachyOS kernel and **compiles `linuxPackages_cachyos` (and its toolchain) from source on the USB stick** — a very long detour that the cache avoids.
|
||||||
Because the install runs as root, and root is a trusted Nix user, the daemon honours these client-supplied substituter settings.
|
Because the install runs as root, and root is a trusted Nix user, the daemon honours these client-supplied substituter settings.
|
||||||
|
|
||||||
Partway through, disko formats the LUKS container and **prompts for a disk-encryption passphrase**.
|
Partway through, disko formats the LUKS container and **prompts for a disk-encryption passphrase**.
|
||||||
This is the passphrase you will type at every boot to unlock the disk.
|
This is the passphrase you will type at every boot to unlock the disk; choose it deliberately.
|
||||||
Choose it deliberately.
|
|
||||||
|
|
||||||
When it finishes it prints `disko-install succeeded`.
|
When it finishes it prints `disko-install succeeded`.
|
||||||
`disko-install` unmounts the target filesystem on exit, so nothing is mounted at this point — step 6 remounts it.
|
`disko-install` unmounts the target filesystem on exit, so nothing is mounted at this point — step 6 remounts it.
|
||||||
|
|||||||
140
flake.lock
generated
140
flake.lock
generated
@@ -75,11 +75,11 @@
|
|||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785327209,
|
"lastModified": 1784318604,
|
||||||
"narHash": "sha256-heXGjUBU1UsTHFzedDzYct9Cblr6FGzQcYyjCykywh8=",
|
"narHash": "sha256-P/N5ZbGWITiTfmiWpE/1uyXdOCagpgw/YAZLZJSzx/I=",
|
||||||
"owner": "chaotic-cx",
|
"owner": "chaotic-cx",
|
||||||
"repo": "nyx",
|
"repo": "nyx",
|
||||||
"rev": "90cfa9864fa08c923dddeca965103ad44663dd64",
|
"rev": "21a8ef816f34558a438d778057a8809322ea2415",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -109,28 +109,6 @@
|
|||||||
"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": {
|
||||||
@@ -245,33 +223,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785340481,
|
"lastModified": 1784592615,
|
||||||
"narHash": "sha256-GSxdQ7w8yYfZnfkXUuZ2fYIKibe9ZU8xDGyqpeTb2tE=",
|
"narHash": "sha256-AH96vm0yYyS9sk35GnagZoWww8s8NHWYuyZJpSStlMM=",
|
||||||
"ref": "refs/heads/main",
|
"ref": "refs/heads/main",
|
||||||
"rev": "627fc9a32bb80d97923540fc0d3e9661961462ba",
|
"rev": "1468003f5b63f49fcd3cd456c25a8ad4f25716cc",
|
||||||
"revCount": 83,
|
"revCount": 82,
|
||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -307,11 +263,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785288465,
|
"lastModified": 1784129366,
|
||||||
"narHash": "sha256-nCkxaGRtyNheNTxoc527gjOG0BN2zovsWDQVBeKDMW8=",
|
"narHash": "sha256-N5JiyICSeQF14x+OQebNyPpYowOT9Rs1iKyeCylSzOA=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "36662afed2fa1c9b69bdd03edb92ad572202ca20",
|
"rev": "165228b0efefc3e635e5174020c40ea64271dc25",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -348,11 +304,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785306346,
|
"lastModified": 1784351324,
|
||||||
"narHash": "sha256-DScBkW0fOgpGPK2trNoX3ryLTlaC14+gglFo/BhGJ4g=",
|
"narHash": "sha256-By+kuRJZRqs2TuXgtR8vJ8cTKWXw33YG/Yollu5cO1U=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "home-manager",
|
"repo": "home-manager",
|
||||||
"rev": "e705714e918c3b11affcdd15db2cbe3a070420a0",
|
"rev": "460108009ca1ff69ca2ff19079ca2c838d6e3080",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -362,28 +318,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -411,11 +345,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785232496,
|
"lastModified": 1784310968,
|
||||||
"narHash": "sha256-65EQYIRRpTdpH8lUiB6Mvo5uBkG60aBIzAJuALfx+O0=",
|
"narHash": "sha256-rkSPTePrKqs4dg+i7ZFCq93+HrClac6oSwXX927SVjA=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixos-hardware",
|
"repo": "nixos-hardware",
|
||||||
"rev": "2e790b0a6be8ec2b76174ac0931b8ff11919ec98",
|
"rev": "779c32a00155994c86cde8213a8dd4df139d4355",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -426,11 +360,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785090369,
|
"lastModified": 1784120854,
|
||||||
"narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=",
|
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "624af665418d3c65d544145b4d34ad696439570e",
|
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -442,11 +376,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785133411,
|
"lastModified": 1784280462,
|
||||||
"narHash": "sha256-Yjv0WEg39KRYS0rBdTbu6Fc/or/ihAKk13W9sQ6VWd0=",
|
"narHash": "sha256-DtoqIqM7VkR6NxAkcLpMwmi02USwWb3JdmNGLyhthc0=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "2f5a153c270b70cb0f8c11f46d96d6d3bc39f4e3",
|
"rev": "293d6abedf0478e681a4dfcfcb35b30fc796a32f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -458,11 +392,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-unstable": {
|
"nixpkgs-unstable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785301185,
|
"lastModified": 1784347607,
|
||||||
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
|
"narHash": "sha256-VI5cdo27nEZ3m1SlgB8RvBbrqFUO2/dUgrrLWe407oA=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
|
"rev": "31cd72fdba8fa052e437ce7e6879c4fe62def10f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -474,11 +408,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785318670,
|
"lastModified": 1784120854,
|
||||||
"narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=",
|
"narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5",
|
"rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -497,11 +431,11 @@
|
|||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785364321,
|
"lastModified": 1784057377,
|
||||||
"narHash": "sha256-BLuHl+nZKb+FDq3GAM6L+UBEiyVepXANA31fT1F56pw=",
|
"narHash": "sha256-yycNej5//EsRbV10moBoh+/63vXEwZD1ZFEiRm6C9rQ=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixvim",
|
"repo": "nixvim",
|
||||||
"rev": "acd69cc15d57004e8cb4495034320263a3d362ea",
|
"rev": "07180a087e4a00720dc0731cbcd8dec796974381",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -539,7 +473,6 @@
|
|||||||
"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",
|
||||||
@@ -555,18 +488,17 @@
|
|||||||
"skills": {
|
"skills": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"gitea-axi": "gitea-axi_2",
|
"home-manager": "home-manager_4",
|
||||||
"home-manager": "home-manager_5",
|
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1785695024,
|
"lastModified": 1784776947,
|
||||||
"narHash": "sha256-DLLk6X5zu3cRT50p18uHVdwjGVtiS0t/661M34q02zU=",
|
"narHash": "sha256-IGVn6Z7dfeArObZvz7cLsGqJD8ipP2VkAZC/bOJgvJI=",
|
||||||
"ref": "refs/heads/main",
|
"ref": "refs/heads/main",
|
||||||
"rev": "9b2a6bcd583d7d6bf7e5377c3632f601692df209",
|
"rev": "1caf18d72dd7cad7304401da0db6a6ba853d8827",
|
||||||
"revCount": 54,
|
"revCount": 5,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.alexion.dev/alexion/skills"
|
"url": "https://git.alexion.dev/alexion/skills"
|
||||||
},
|
},
|
||||||
|
|||||||
41
flake.nix
41
flake.nix
@@ -16,27 +16,19 @@
|
|||||||
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";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
|
||||||
# Declarative disk partitioning.
|
# Declarative disk partitioning; each host declares its own layout.
|
||||||
# Each host declares its own layout.
|
|
||||||
disko = {
|
disko = {
|
||||||
url = "github:nix-community/disko";
|
url = "github:nix-community/disko";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
|
||||||
# Upstream per-machine hardware profiles.
|
# Upstream per-machine hardware profiles; each host imports its own.
|
||||||
# Each host imports its own.
|
|
||||||
nixos-hardware = {
|
nixos-hardware = {
|
||||||
url = "github:NixOS/nixos-hardware";
|
url = "github:NixOS/nixos-hardware";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
@@ -55,20 +47,22 @@
|
|||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
|
||||||
# Agent-ergonomic CLI for Gitea, with a home-manager module for the agent context.
|
# Agent-ergonomic CLI for Gitea, with its home-manager module wiring in the
|
||||||
|
# Claude Code context where that harness is present.
|
||||||
gitea-axi = {
|
gitea-axi = {
|
||||||
url = "git+https://git.alexion.dev/alexion/gitea-axi";
|
url = "git+https://git.alexion.dev/alexion/gitea-axi";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
|
||||||
# Personal agent skills, packaged as per-skill derivations with a home-manager module.
|
# Personal agent skills, packaged as per-skill derivations with a
|
||||||
|
# home-manager module that places them under Claude Code's skills directory.
|
||||||
skills = {
|
skills = {
|
||||||
url = "git+https://git.alexion.dev/alexion/skills";
|
url = "git+https://git.alexion.dev/alexion/skills";
|
||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
|
||||||
# CachyOS kernel and binary cache.
|
# CachyOS kernel and binary cache. Pins its own nixpkgs so its cache stays
|
||||||
# Pins its own nixpkgs so its cache stays usable and the kernel is fetched from it.
|
# usable and the kernel is fetched from it.
|
||||||
chaotic.url = "github:chaotic-cx/nyx/nyxpkgs-unstable";
|
chaotic.url = "github:chaotic-cx/nyx/nyxpkgs-unstable";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,26 +79,9 @@
|
|||||||
# 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:
|
_name: host: host.config.system.build.toplevel
|
||||||
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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
31
guest.nix
31
guest.nix
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
my,
|
|
||||||
inputs,
|
|
||||||
lib,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
# The guest-base: the slim foundation every nested guest's interior stands on.
|
|
||||||
# It imports the full modules tree so any module is available to enable inside a
|
|
||||||
# guest, and stands on the same shared base a host does.
|
|
||||||
{
|
|
||||||
imports = my.collectNixFiles (inputs.self + "/modules") ++ [
|
|
||||||
(inputs.self + "/base.nix")
|
|
||||||
|
|
||||||
# The modules tree reaches for these option namespaces, so they must be
|
|
||||||
# declared for the tree to evaluate even where a guest leaves them off.
|
|
||||||
inputs.sops-nix.nixosModules.sops
|
|
||||||
inputs.stylix.nixosModules.stylix
|
|
||||||
];
|
|
||||||
|
|
||||||
# A nested container has no per-host `default.nix` to pin its release.
|
|
||||||
system.stateVersion = "26.05";
|
|
||||||
|
|
||||||
# The baseline toolset and SSH access, so any guest shelled into is a workable
|
|
||||||
# environment without per-guest wiring.
|
|
||||||
modules.toolkit.enable = lib.mkDefault true;
|
|
||||||
modules.ssh.enable = lib.mkDefault true;
|
|
||||||
|
|
||||||
# A guest carries no host identity, so it presents a self-generated host key
|
|
||||||
# rather than restoring one from secrets.
|
|
||||||
modules.ssh.hostKeys.restore = lib.mkDefault false;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
args@{ my, ... }:
|
|
||||||
# The tracer-bullet guest: the thinnest complete path from discovery to a
|
|
||||||
# running nested container. Its interior is just the guest-base — the baseline
|
|
||||||
# toolset and SSH access — so it proves the concept without carrying a service.
|
|
||||||
my.guest { name = "sample"; } args
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
|
config,
|
||||||
inputs,
|
inputs,
|
||||||
pkgs,
|
pkgs,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
# neogaia — Dell XPS 13 9380 laptop.
|
# neogaia — Dell XPS 13 9380 laptop.
|
||||||
# Disk layout is in ./disk.nix.
|
# Disk layout is in ./disk.nix; `fileSystems` are derived from it, none declared here.
|
||||||
# `fileSystems` are derived from it, none declared here.
|
|
||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
inputs.nixos-hardware.nixosModules.dell-xps-13-9380
|
inputs.nixos-hardware.nixosModules.dell-xps-13-9380
|
||||||
@@ -22,10 +22,10 @@
|
|||||||
boot.kernelPackages = pkgs.linuxPackages_cachyos;
|
boot.kernelPackages = pkgs.linuxPackages_cachyos;
|
||||||
|
|
||||||
# Redistributable firmware for the QCA6174 wifi (ath10k blobs).
|
# Redistributable firmware for the QCA6174 wifi (ath10k blobs).
|
||||||
# Intel microcode updates follow from this, so none is declared here.
|
# Intel microcode updates follow from this; none declared here.
|
||||||
hardware.enableRedistributableFirmware = true;
|
hardware.enableRedistributableFirmware = true;
|
||||||
|
|
||||||
# RAM-backed swap, no on-disk swap partition.
|
# RAM-backed swap; no on-disk swap partition.
|
||||||
zramSwap.enable = true;
|
zramSwap.enable = true;
|
||||||
|
|
||||||
# So wifi can be joined from the console.
|
# So wifi can be joined from the console.
|
||||||
@@ -38,38 +38,21 @@
|
|||||||
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;
|
||||||
|
|
||||||
modules.toolkit.enable = true;
|
# A machine the operator works from, so it admits the workstation keys alone.
|
||||||
|
modules.ssh.authorizedKeys = config.modules.ssh.workstationKeys;
|
||||||
|
|
||||||
# The walking-skeleton guest, enabled like any module: proves the guest path
|
# fish as the login shell.
|
||||||
# end to end through this host's `nix flake check`.
|
modules.fish.enable = true;
|
||||||
# Modest caps keep the skeleton guest from starving the laptop.
|
modules.fish.defaultShell = 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.git.enable = true;
|
||||||
|
modules.tmux.enable = true;
|
||||||
|
modules.nvim.enable = true;
|
||||||
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.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";
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{ ... }:
|
{ ... }:
|
||||||
# neogaia's disk layout for disko: one NVMe disk, GPT, with an EFI system
|
# neogaia's disk layout for disko: one NVMe disk, GPT, with an EFI system
|
||||||
# partition and a LUKS container holding btrfs subvolumes.
|
# partition and a LUKS container holding btrfs subvolumes. No swap partition;
|
||||||
# No swap partition, since swap is zram.
|
# swap is zram. disko derives `fileSystems` and `boot.initrd.luks.devices` from this.
|
||||||
# disko derives `fileSystems` and `boot.initrd.luks.devices` from this.
|
|
||||||
{
|
{
|
||||||
disko.devices.disk.main = {
|
disko.devices.disk.main = {
|
||||||
type = "disk";
|
type = "disk";
|
||||||
@@ -11,8 +10,8 @@
|
|||||||
type = "gpt";
|
type = "gpt";
|
||||||
partitions = {
|
partitions = {
|
||||||
ESP = {
|
ESP = {
|
||||||
# Each generation stores a kernel and initrd here and the CachyOS kernel is large.
|
# Each generation stores a kernel and initrd here and the CachyOS
|
||||||
# An exhausted partition fails bootloader installs.
|
# kernel is large; an exhausted partition fails bootloader installs.
|
||||||
size = "2G";
|
size = "2G";
|
||||||
type = "EF00";
|
type = "EF00";
|
||||||
content = {
|
content = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{ lib, modulesPath, ... }:
|
{ lib, modulesPath, ... }:
|
||||||
# Hardware detected by nixos-generate-config on this machine.
|
# Hardware detected by nixos-generate-config on this machine.
|
||||||
# disko derives `fileSystems` and the LUKS device, none declared here.
|
# disko derives `fileSystems` and the LUKS device; none declared here.
|
||||||
{
|
{
|
||||||
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
|
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
{ 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";
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{ ... }:
|
|
||||||
# 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 = "/";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{ 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 +0,0 @@
|
|||||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
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
|
|
||||||
366
lib.nix
366
lib.nix
@@ -32,43 +32,22 @@ let
|
|||||||
) (builtins.readDir dir)
|
) (builtins.readDir dir)
|
||||||
);
|
);
|
||||||
|
|
||||||
# The special arguments every configuration is evaluated with, host and guest
|
# Build one host: every module is imported unconditionally (inert until its
|
||||||
# interior alike.
|
# `enable` flag is set), alongside home-manager, chaotic, the shared base, and
|
||||||
specialArgs = {
|
# the host's own directory.
|
||||||
inherit inputs;
|
|
||||||
my = self.lib;
|
|
||||||
};
|
|
||||||
|
|
||||||
# The name of a tagged VLAN's bridge, kept here as the one definition of a
|
|
||||||
# convention shared across the flake.
|
|
||||||
bridgeName = id: "br-vlan${toString id}";
|
|
||||||
|
|
||||||
# A guest with no operator-set MAC derives a stable one from its namespace path.
|
|
||||||
# The first octet 02 marks the address locally-administered and unicast.
|
|
||||||
# The rest is a slice of the path's hash.
|
|
||||||
# The same guest therefore always lands on the same address, which the operator can reserve at the router.
|
|
||||||
deriveMac =
|
|
||||||
name:
|
|
||||||
let
|
|
||||||
hash = builtins.hashString "sha256" name;
|
|
||||||
octet = i: builtins.substring (i * 2) 2 hash;
|
|
||||||
in
|
|
||||||
lib.concatStringsSep ":" ([ "02" ] ++ map octet [ 0 1 2 3 4 ]);
|
|
||||||
|
|
||||||
# Build one host: every module and every guest is imported unconditionally
|
|
||||||
# (inert until its `enable` flag is set), alongside chaotic, the host base,
|
|
||||||
# and the host's own directory.
|
|
||||||
mkHost =
|
mkHost =
|
||||||
{
|
{
|
||||||
hostName,
|
hostName,
|
||||||
system ? "x86_64-linux",
|
system ? "x86_64-linux",
|
||||||
}:
|
}:
|
||||||
inputs.nixpkgs.lib.nixosSystem {
|
inputs.nixpkgs.lib.nixosSystem {
|
||||||
inherit system specialArgs;
|
inherit system;
|
||||||
modules =
|
specialArgs = {
|
||||||
(collectNixFiles (self + "/modules"))
|
inherit inputs;
|
||||||
++ (collectNixFiles (self + "/guests"))
|
my = self.lib;
|
||||||
++ [
|
};
|
||||||
|
modules = (collectNixFiles (self + "/modules")) ++ [
|
||||||
|
inputs.home-manager.nixosModules.home-manager
|
||||||
inputs.chaotic.nixosModules.default
|
inputs.chaotic.nixosModules.default
|
||||||
inputs.disko.nixosModules.disko
|
inputs.disko.nixosModules.disko
|
||||||
inputs.sops-nix.nixosModules.sops
|
inputs.sops-nix.nixosModules.sops
|
||||||
@@ -79,329 +58,6 @@ let
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
# Build a guest: a module-shaped definition whose body realizes its interior
|
|
||||||
# as a nested container standing on the guest-base, keyed by its namespace path.
|
|
||||||
# `name` is the dotted namespace under `guests.` and `interior` is an extra
|
|
||||||
# module merged into the container alongside the guest-base.
|
|
||||||
guest =
|
|
||||||
{
|
|
||||||
name,
|
|
||||||
interior ? { },
|
|
||||||
}:
|
|
||||||
{ config, lib, ... }:
|
|
||||||
let
|
|
||||||
optionPath = [ "guests" ] ++ lib.splitString "." name;
|
|
||||||
cfg = lib.getAttrFromPath optionPath config;
|
|
||||||
machineName = lib.replaceStrings [ "." ] [ "-" ] name;
|
|
||||||
|
|
||||||
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.
|
|
||||||
# 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.
|
|
||||||
guestNet =
|
|
||||||
{ lib, ... }:
|
|
||||||
{
|
|
||||||
config = lib.mkIf networked {
|
|
||||||
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" = {
|
|
||||||
matchConfig.Name = "eth0";
|
|
||||||
linkConfig.MACAddress = cfg.mac;
|
|
||||||
networkConfig = lib.mkIf (cfg.address == null) { DHCP = "yes"; };
|
|
||||||
address = lib.mkIf (cfg.address != null) [ cfg.address ];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in
|
|
||||||
{
|
|
||||||
options = lib.setAttrByPath optionPath {
|
|
||||||
enable = lib.mkEnableOption "the ${name} guest, run in its own nested container";
|
|
||||||
backend = lib.mkOption {
|
|
||||||
type = lib.types.enum [
|
|
||||||
"container"
|
|
||||||
"microvm"
|
|
||||||
];
|
|
||||||
default = "container";
|
|
||||||
description = ''
|
|
||||||
How the guest is realized. `container` runs the guest as a
|
|
||||||
systemd-nspawn nested container. `microvm` is reserved for a future
|
|
||||||
hard-isolation backend and is not built yet.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
vlan = lib.mkOption {
|
|
||||||
type = lib.types.nullOr (lib.types.ints.between 1 4094);
|
|
||||||
default = null;
|
|
||||||
example = 10;
|
|
||||||
description = ''
|
|
||||||
The tagged VLAN this guest lives on. The guest attaches to its host's
|
|
||||||
`br-vlan<id>` bridge for that VLAN. Left null, the guest keeps a
|
|
||||||
private network with no bridge attachment. The id must be one of the
|
|
||||||
host's `modules.network.vlans`.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
mac = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = deriveMac name;
|
|
||||||
defaultText = lib.literalMD "a stable address derived from the guest's namespace path";
|
|
||||||
example = "bc:24:11:00:00:01";
|
|
||||||
description = ''
|
|
||||||
The guest's MAC address on its VLAN, pinned inside the guest by its
|
|
||||||
own networkd. Set it to reuse an existing address so a router's DHCP
|
|
||||||
reservation keeps working. Left unset, a stable address is derived
|
|
||||||
from the guest's namespace path in the locally-administered range.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
address = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
default = null;
|
|
||||||
example = "10.0.10.5/24";
|
|
||||||
description = ''
|
|
||||||
The guest's static address, in CIDR form, on its VLAN. Left null, the
|
|
||||||
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 {
|
|
||||||
# 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 = [
|
|
||||||
{
|
|
||||||
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";
|
|
||||||
message = ''
|
|
||||||
guests.${name}.backend = "${cfg.backend}" is not implemented. Only the "container" backend is built; "microvm" is reserved for future work.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = !networked || lib.elem cfg.vlan config.modules.network.vlans;
|
|
||||||
message = ''
|
|
||||||
guests.${name}.vlan = ${toString cfg.vlan} is not among its host's modules.network.vlans (${lib.concatMapStringsSep ", " toString config.modules.network.vlans}). Declare the VLAN on the host or correct the guest's placement.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
# 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") {
|
|
||||||
autoStart = cfg.autoStart;
|
|
||||||
|
|
||||||
# The guest gets its own network namespace, so its services — its own
|
|
||||||
# sshd included — never contend with the host's.
|
|
||||||
privateNetwork = lib.mkDefault true;
|
|
||||||
|
|
||||||
# A networked guest's veth is enslaved to the VLAN's bridge, making it
|
|
||||||
# a first-class L2 citizen on that segment.
|
|
||||||
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;
|
|
||||||
|
|
||||||
config = {
|
|
||||||
imports = [
|
|
||||||
(self + "/guest.nix")
|
|
||||||
guestNet
|
|
||||||
interior
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# Discover every host (a subdirectory of `hostsDir`) and build each one.
|
# Discover every host (a subdirectory of `hostsDir`) and build each one.
|
||||||
mkHosts =
|
mkHosts =
|
||||||
hostsDir:
|
hostsDir:
|
||||||
@@ -415,7 +71,5 @@ in
|
|||||||
collectNixFiles
|
collectNixFiles
|
||||||
mkHost
|
mkHost
|
||||||
mkHosts
|
mkHosts
|
||||||
guest
|
|
||||||
bridgeName
|
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,14 +30,11 @@ 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 AGENTS.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 CLAUDE.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 AGENTS.md files, use the one nearest to where the mistake occurred, falling back to the project's top-level AGENTS.md.
|
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.
|
||||||
Append to an existing AGENTS.md immediately, without asking; if no AGENTS.md exists yet for the project, ask before creating one.
|
Append to an existing CLAUDE.md immediately, without asking; if no CLAUDE.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.
|
||||||
|
|
||||||
@@ -49,7 +46,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 `.agents/`, `.claude/`, `AGENTS.md`, or `CLAUDE.md`).
|
Never reference agent-facing state (anything under `.claude/` 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.
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
# Claude Code for the primary user, configured through home-manager, which ships
|
# Claude Code for the primary user, configured through home-manager, which ships
|
||||||
# the package and manages ~/.claude.
|
# the package and manages ~/.claude. Login credentials are left unmanaged so they
|
||||||
# Login credentials are left unmanaged so they survive rebuilds.
|
# survive rebuilds.
|
||||||
let
|
let
|
||||||
cfg = config.modules.agents.claude-code;
|
cfg = config.modules.agents.claude-code;
|
||||||
user = config.user.name;
|
user = config.user.name;
|
||||||
@@ -22,8 +22,10 @@ in
|
|||||||
cached credential while it lasts. Suitable for a single-user machine'';
|
cached credential while it lasts. Suitable for a single-user machine'';
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
# Key the credential cache per user rather than per terminal, so one
|
# Keying sudo's credential cache per user rather than per terminal lets one
|
||||||
# authentication covers the agent's terminal-less commands.
|
# authentication cover commands issued by processes holding no terminal of
|
||||||
|
# their own. Any process running as this user can spend that credential
|
||||||
|
# until it lapses, so this suits a single-user machine.
|
||||||
security.sudo.extraConfig = ''
|
security.sudo.extraConfig = ''
|
||||||
Defaults timestamp_type=global
|
Defaults timestamp_type=global
|
||||||
Defaults timestamp_timeout=60
|
Defaults timestamp_timeout=60
|
||||||
@@ -36,6 +38,9 @@ in
|
|||||||
programs.claude-code = {
|
programs.claude-code = {
|
||||||
enable = true;
|
enable = true;
|
||||||
|
|
||||||
|
# Global agent instructions, rendered to ~/.claude/CLAUDE.md.
|
||||||
|
context = ./CLAUDE.md;
|
||||||
|
|
||||||
# One directory per skill, symlinked under ~/.claude/skills.
|
# One directory per skill, symlinked under ~/.claude/skills.
|
||||||
skills = ./skills;
|
skills = ./skills;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Refuse a privileged command while sudo's credential cache is cold, naming the
|
# agent-sudo-guard.sh — refuse a privileged command while sudo's credential
|
||||||
# command that warms it.
|
# cache is cold, naming the command that warms it.
|
||||||
#
|
#
|
||||||
# Commands arrive here from subprocesses holding no terminal, so an uncached
|
# Commands arrive here from subprocesses holding no terminal, so an uncached
|
||||||
# sudo fails with a bare non-zero exit and no output, reading as an unexplained stall.
|
# sudo fails with a bare non-zero exit and no output, reading as an unexplained
|
||||||
# The probe below reads a cache keyed per user rather than per terminal,
|
# stall. The probe below reads a cache keyed per user rather than per terminal,
|
||||||
# so an authentication made in the operator's own terminal counts.
|
# so an authentication made in the operator's own terminal counts.
|
||||||
|
|
||||||
input=$(cat)
|
input=$(cat)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# ADR Format
|
||||||
|
|
||||||
|
ADRs live in `.claude/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc.
|
||||||
|
|
||||||
|
Create the `.claude/adr/` directory lazily — only when the first ADR is needed.
|
||||||
|
|
||||||
|
## Template
|
||||||
|
|
||||||
|
```md
|
||||||
|
# {Short title of the decision}
|
||||||
|
|
||||||
|
{1-3 sentences: what's the context, what did we decide, and why.}
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections.
|
||||||
|
|
||||||
|
## Optional sections
|
||||||
|
|
||||||
|
Only include these when they add genuine value. Most ADRs won't need them.
|
||||||
|
|
||||||
|
- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited
|
||||||
|
- **Considered Options** — only when the rejected alternatives are worth remembering
|
||||||
|
- **Consequences** — only when non-obvious downstream effects need to be called out
|
||||||
|
|
||||||
|
## Numbering
|
||||||
|
|
||||||
|
Scan `.claude/adr/` for the highest existing number and increment by one.
|
||||||
|
|
||||||
|
## When to offer an ADR
|
||||||
|
|
||||||
|
All three of these must be true:
|
||||||
|
|
||||||
|
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
||||||
|
2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?"
|
||||||
|
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
||||||
|
|
||||||
|
If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing."
|
||||||
|
|
||||||
|
### What qualifies
|
||||||
|
|
||||||
|
- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres."
|
||||||
|
- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP."
|
||||||
|
- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out.
|
||||||
|
- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s.
|
||||||
|
- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate.
|
||||||
|
- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract."
|
||||||
|
- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# CONTEXT.md Format
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```md
|
||||||
|
# {Context Name}
|
||||||
|
|
||||||
|
{One or two sentence description of what this context is and why it exists.}
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
**Order**:
|
||||||
|
{A one or two sentence description of the term}
|
||||||
|
_Avoid_: Purchase, transaction
|
||||||
|
|
||||||
|
**Invoice**:
|
||||||
|
A request for payment sent to a customer after delivery.
|
||||||
|
_Avoid_: Bill, payment request
|
||||||
|
|
||||||
|
**Customer**:
|
||||||
|
A person or organization that places orders.
|
||||||
|
_Avoid_: Client, buyer, account
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others under `_Avoid_`.
|
||||||
|
- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does.
|
||||||
|
- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs.
|
||||||
|
- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine.
|
||||||
56
modules/agents/claude-code/skills/domain-modeling/SKILL.md
Normal file
56
modules/agents/claude-code/skills/domain-modeling/SKILL.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
name: domain-modeling
|
||||||
|
description: Build and sharpen a project's domain model. Use when the user wants to pin down domain terminology or a ubiquitous language, record an architectural decision, or when another skill needs to maintain the domain model.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Domain Modeling
|
||||||
|
|
||||||
|
Actively build and sharpen the project's domain model as you design. This is the *active* discipline — challenging terms, inventing edge-case scenarios, and writing the glossary and decisions down the moment they crystallise. (Merely *reading* `.claude/CONTEXT.md` for vocabulary is not this skill — that's a one-line habit any skill can do. This skill is for when you're changing the model, not just consuming it.)
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── .claude/
|
||||||
|
│ ├── CONTEXT.md
|
||||||
|
│ └── adr/
|
||||||
|
│ ├── 0001-event-sourced-orders.md
|
||||||
|
│ └── 0002-postgres-for-write-model.md
|
||||||
|
└── src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Create files lazily — only when you have something to write. If no `.claude/CONTEXT.md` exists, create it when the first term is resolved. If no `.claude/adr/` exists, create it when the first ADR is needed.
|
||||||
|
|
||||||
|
## During the session
|
||||||
|
|
||||||
|
### Challenge against the glossary
|
||||||
|
|
||||||
|
When the user uses a term that conflicts with the existing language in `.claude/CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"
|
||||||
|
|
||||||
|
### Sharpen fuzzy language
|
||||||
|
|
||||||
|
When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."
|
||||||
|
|
||||||
|
### Discuss concrete scenarios
|
||||||
|
|
||||||
|
When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.
|
||||||
|
|
||||||
|
### Cross-reference with code
|
||||||
|
|
||||||
|
When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"
|
||||||
|
|
||||||
|
### Update .claude/CONTEXT.md inline
|
||||||
|
|
||||||
|
When a term is resolved, update `.claude/CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).
|
||||||
|
|
||||||
|
`.claude/CONTEXT.md` should be totally devoid of implementation details. Do not treat `.claude/CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.
|
||||||
|
|
||||||
|
### Offer ADRs sparingly
|
||||||
|
|
||||||
|
Only offer to create an ADR when all three are true:
|
||||||
|
|
||||||
|
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
||||||
|
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
|
||||||
|
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
||||||
|
|
||||||
|
If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).
|
||||||
20
modules/agents/claude-code/skills/grill/SKILL.md
Normal file
20
modules/agents/claude-code/skills/grill/SKILL.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: grill
|
||||||
|
description: Interview the user relentlessly about a plan or design, capturing the resolved terms and decisions into the project's domain model as you go if one exists. Use when the user wants to stress-test a plan before building, or uses any 'grill' trigger phrase.
|
||||||
|
---
|
||||||
|
|
||||||
|
Interview me relentlessly about every aspect of this plan or design. Walk down each branch of the design tree, resolving dependencies between decisions one by one, and give your recommended answer for each question. Keep going until every branch carries an explicit decision and no dependency between decisions is left open — not merely until it feels like "we understand each other."
|
||||||
|
|
||||||
|
Ask the questions one at a time, waiting for feedback on each before continuing. Asking several at once is bewildering.
|
||||||
|
|
||||||
|
If a question can be answered by exploring the codebase, explore the codebase instead of asking it.
|
||||||
|
|
||||||
|
**Never start implementation during or after the interview without an explicit instruction from the user.** This applies at every point — mid-interview and after the final question alike.
|
||||||
|
|
||||||
|
## Closing the interview
|
||||||
|
|
||||||
|
When every branch carries an explicit decision and no dependency is left open, produce a concise summary of all decisions reached, then stop and wait for the user's next instruction.
|
||||||
|
|
||||||
|
## Tracking the domain model as you go
|
||||||
|
|
||||||
|
If a `.claude/CONTEXT.md` file exists in the project, also run [`domain-modeling`](../domain-modeling/SKILL.md) alongside this interview: resolve each term into `.claude/CONTEXT.md` the moment it crystallizes, and offer an ADR using that skill's own criteria — hard to reverse, surprising without context, and the result of a real trade-off. If no `.claude/CONTEXT.md` exists, run the interview alone with no doc side effects.
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
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;
|
|
||||||
})
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
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
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
29
modules/agents/pi.nix
Normal file
29
modules/agents/pi.nix
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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'")));
|
|
||||||
});
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
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}`;
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
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"]);
|
|
||||||
});
|
|
||||||
@@ -1,558 +0,0 @@
|
|||||||
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()}…`;
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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));
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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)}…`;
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
{
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,18 @@
|
|||||||
{
|
{ config, inputs, ... }:
|
||||||
config,
|
# Global agent skills from the skills flake, placed under the agent harness's
|
||||||
inputs,
|
# skills directory so they are active in every project. The flake's home-manager
|
||||||
pkgs,
|
# module self-gates on the harness being enabled and installs nothing for an
|
||||||
...
|
# empty selection, so a host without one carries no skills either way.
|
||||||
}:
|
#
|
||||||
# Global agent skills, placed under the skills directory so they are active in
|
# Unlike every other module, this one declares no `enable` flag and wires
|
||||||
# every project.
|
# unconditionally, by design.
|
||||||
|
# The flake's self-gating above already makes it inert where the harness is
|
||||||
|
# absent, so a gate would guard nothing.
|
||||||
let
|
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.
|
||||||
# grill interviews the operator relentlessly to resolve a plan before building.
|
skills = [ ];
|
||||||
# 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}; [
|
|
||||||
grill
|
|
||||||
design-skill
|
|
||||||
wayfinder
|
|
||||||
research
|
|
||||||
prototype
|
|
||||||
slice
|
|
||||||
subagents
|
|
||||||
implement
|
|
||||||
test-driven-development
|
|
||||||
review
|
|
||||||
];
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
home-manager.sharedModules = [ inputs.skills.homeModules.default ];
|
home-manager.sharedModules = [ inputs.skills.homeModules.default ];
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
# gitea-axi for the primary user, installed through its own home-manager module.
|
# gitea-axi for the primary user, installed through its own home-manager module.
|
||||||
|
# That module also declares the Claude Code context when that harness is
|
||||||
|
# enabled on the host; enabling this alone installs the CLI and nothing else.
|
||||||
let
|
let
|
||||||
cfg = config.modules.agents.tools.gitea-axi;
|
cfg = config.modules.agents.tools.gitea-axi;
|
||||||
user = config.user.name;
|
user = config.user.name;
|
||||||
|
|||||||
@@ -25,6 +25,5 @@ in
|
|||||||
modules.desktop.theming.enable = lib.mkDefault true;
|
modules.desktop.theming.enable = lib.mkDefault true;
|
||||||
modules.desktop.waybar.enable = lib.mkDefault true;
|
modules.desktop.waybar.enable = lib.mkDefault true;
|
||||||
modules.desktop.audio.enable = lib.mkDefault true;
|
modules.desktop.audio.enable = lib.mkDefault true;
|
||||||
modules.desktop.osd.enable = lib.mkDefault true;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,48 +34,20 @@ in
|
|||||||
SponsoredPocket = false;
|
SponsoredPocket = false;
|
||||||
Snippets = false;
|
Snippets = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# An ad and content blocker, the operator's password manager, and a
|
||||||
|
# video sponsor-skipper. All three are self-contained web extensions.
|
||||||
|
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;
|
||||||
|
|
||||||
extensions = {
|
|
||||||
packages = with firefoxAddons; [
|
|
||||||
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
|
|
||||||
# near-white grey, leaving its light text unreadable. Darken that one
|
|
||||||
# highlight to the Nord selection grey, from the same scheme.
|
|
||||||
extensions.settings."FirefoxColor@mozilla.com".settings.theme.colors.popup_highlight =
|
|
||||||
let
|
|
||||||
c = hm.config.lib.stylix.colors;
|
|
||||||
in
|
|
||||||
lib.mkForce {
|
|
||||||
r = c."base03-rgb-r";
|
|
||||||
g = c."base03-rgb-g";
|
|
||||||
b = c."base03-rgb-b";
|
|
||||||
};
|
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
# Scale the UI and page by a fixed factor.
|
|
||||||
# Left at auto (-1), Firefox reads the panel's 1.5x and inflates its
|
|
||||||
# whole chrome while point-sized apps stay put.
|
|
||||||
# A shade under that brings it into line without dropping to true
|
|
||||||
# 1:1, which reads too small at this DPI.
|
|
||||||
"layout.css.devPixelsPerPx" = "1.25";
|
|
||||||
|
|
||||||
# Auto-enable the sideloaded Firefox Color add-on carrying the Nord
|
|
||||||
# chrome theme, which Firefox otherwise leaves disabled.
|
|
||||||
"extensions.autoDisableScopes" = 0;
|
|
||||||
|
|
||||||
# Sponsored surfaces the policies above do not reach.
|
# Sponsored surfaces the policies above do not reach.
|
||||||
"browser.urlbar.suggest.quicksuggest.sponsored" = false;
|
"browser.urlbar.suggest.quicksuggest.sponsored" = false;
|
||||||
"browser.newtabpage.activity-stream.showSponsored" = false;
|
"browser.newtabpage.activity-stream.showSponsored" = false;
|
||||||
@@ -102,8 +74,9 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Nord chrome for the one profile, applied through the Stylix-managed
|
# Nord chrome from the shared Stylix scheme, against the one profile.
|
||||||
# Firefox Color add-on that colorTheme enables.
|
# colorTheme recolours the toolbar and tabs, which the target does not do
|
||||||
|
# on its own, through the Stylix-managed Firefox Color add-on.
|
||||||
stylix.targets.firefox = {
|
stylix.targets.firefox = {
|
||||||
enable = true;
|
enable = true;
|
||||||
profileNames = [ "default" ];
|
profileNames = [ "default" ];
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
pkgs,
|
pkgs,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
# hypridle: idle-triggered locking and display power management.
|
# Idle management: hypridle locks on idle, powers the displays off, and locks
|
||||||
|
# before every suspend, so an unattended session always lands at hyprlock.
|
||||||
let
|
let
|
||||||
cfg = config.modules.desktop.hyprland.hypridle;
|
cfg = config.modules.desktop.hyprland.hypridle;
|
||||||
user = config.user.name;
|
user = config.user.name;
|
||||||
|
|||||||
@@ -4,11 +4,10 @@
|
|||||||
pkgs,
|
pkgs,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
# The Hyprland compositor.
|
# The Hyprland compositor, sourced from nixpkgs.
|
||||||
let
|
let
|
||||||
cfg = config.modules.desktop.hyprland;
|
cfg = config.modules.desktop.hyprland;
|
||||||
user = config.user.name;
|
user = config.user.name;
|
||||||
cursor = config.stylix.cursor;
|
|
||||||
|
|
||||||
# Numbered-workspace switch and move for 1..9, the operator's i3 muscle memory.
|
# Numbered-workspace switch and move for 1..9, the operator's i3 muscle memory.
|
||||||
workspaceBinds = lib.concatMap (n: [
|
workspaceBinds = lib.concatMap (n: [
|
||||||
@@ -63,33 +62,12 @@ in
|
|||||||
"$mod" = "SUPER";
|
"$mod" = "SUPER";
|
||||||
"$terminal" = "alacritty";
|
"$terminal" = "alacritty";
|
||||||
|
|
||||||
# Session variables handed to the compositor directly.
|
|
||||||
# UWSM launches the session without the shell profile that would carry
|
|
||||||
# them, so a variable the compositor or its children must see is set
|
|
||||||
# here rather than through home-manager's sessionVariables.
|
|
||||||
env =
|
|
||||||
[
|
|
||||||
# Chromium and Electron apps read this to select native Wayland;
|
|
||||||
# nixpkgs wrappers (Obsidian's included) gate their Wayland flags on
|
|
||||||
# it, so without it they run under XWayland and blur at this DPI.
|
|
||||||
"NIXOS_OZONE_WL,1"
|
|
||||||
]
|
|
||||||
# Bibata ships XCursor only.
|
|
||||||
# The hyprcursor variables name the same theme, which Hyprland
|
|
||||||
# resolves through its XCursor fallback.
|
|
||||||
++ lib.optionals (cursor != null) [
|
|
||||||
"XCURSOR_THEME,${cursor.name}"
|
|
||||||
"XCURSOR_SIZE,${toString cursor.size}"
|
|
||||||
"HYPRCURSOR_THEME,${cursor.name}"
|
|
||||||
"HYPRCURSOR_SIZE,${toString cursor.size}"
|
|
||||||
];
|
|
||||||
|
|
||||||
input = {
|
input = {
|
||||||
kb_layout = "us";
|
kb_layout = "us";
|
||||||
# Caps is a second Escape.
|
# Caps is a second Escape.
|
||||||
# Shift+Caps still toggles a real CapsLock.
|
# Shift+Caps still toggles a real CapsLock.
|
||||||
kb_options = "caps:escape_shifted_capslock";
|
kb_options = "caps:escape_shifted_capslock";
|
||||||
# Snappy key repeat.
|
# Snappy: a short delay before repeat begins, then a fast repeat rate.
|
||||||
repeat_delay = 250;
|
repeat_delay = 250;
|
||||||
repeat_rate = 45;
|
repeat_rate = 45;
|
||||||
accel_profile = "flat";
|
accel_profile = "flat";
|
||||||
@@ -112,10 +90,6 @@ 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" ];
|
||||||
@@ -167,20 +141,15 @@ in
|
|||||||
]
|
]
|
||||||
++ workspaceBinds;
|
++ workspaceBinds;
|
||||||
|
|
||||||
# Volume and brightness keys repeat while held, each raising a popup
|
# Volume keys repeat while held, capped at 150 percent.
|
||||||
# through the OSD client.
|
|
||||||
# Volume is capped at 100 percent.
|
|
||||||
# The client floors brightness so a full hold cannot black the screen out.
|
|
||||||
binde = [
|
binde = [
|
||||||
", XF86AudioRaiseVolume, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume raise --max-volume 100"
|
", XF86AudioRaiseVolume, exec, ${pkgs.wireplumber}/bin/wpctl set-volume -l 1.5 @DEFAULT_AUDIO_SINK@ 5%+"
|
||||||
", XF86AudioLowerVolume, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume lower"
|
", XF86AudioLowerVolume, exec, ${pkgs.wireplumber}/bin/wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"
|
||||||
", XF86MonBrightnessUp, exec, ${pkgs.swayosd}/bin/swayosd-client --brightness raise"
|
|
||||||
", XF86MonBrightnessDown, exec, ${pkgs.swayosd}/bin/swayosd-client --brightness lower"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
# Mute and media transport still fire while the session is locked.
|
# Mute and media transport still fire while the session is locked.
|
||||||
bindl = [
|
bindl = [
|
||||||
", XF86AudioMute, exec, ${pkgs.swayosd}/bin/swayosd-client --output-volume mute-toggle"
|
", XF86AudioMute, exec, ${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
|
||||||
", XF86AudioPlay, exec, ${pkgs.playerctl}/bin/playerctl play-pause"
|
", XF86AudioPlay, exec, ${pkgs.playerctl}/bin/playerctl play-pause"
|
||||||
", XF86AudioNext, exec, ${pkgs.playerctl}/bin/playerctl next"
|
", XF86AudioNext, exec, ${pkgs.playerctl}/bin/playerctl next"
|
||||||
", XF86AudioPrev, exec, ${pkgs.playerctl}/bin/playerctl previous"
|
", XF86AudioPrev, exec, ${pkgs.playerctl}/bin/playerctl previous"
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
pkgs,
|
pkgs,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
# The lock screen: a hyprlock session-lock surface the compositor owns.
|
# The lock screen: hyprlock, a session-lock client whose surface the compositor owns, so it survives a crash of the locker rather than exposing the session.
|
||||||
let
|
let
|
||||||
cfg = config.modules.desktop.hyprland.hyprlock;
|
cfg = config.modules.desktop.hyprland.hyprlock;
|
||||||
user = config.user.name;
|
user = config.user.name;
|
||||||
|
|
||||||
# The hyprlock this module installs, used by the lock keybind below.
|
# The hyprlock this module installs, so the keybind and the idle daemon lock
|
||||||
|
# with one package and never split versions.
|
||||||
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
|
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
@@ -29,8 +30,7 @@ in
|
|||||||
disable_loading_bar = true;
|
disable_loading_bar = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
# A centered password field.
|
# A centered password field; its colors are the Stylix target's.
|
||||||
# Its colors come from the Stylix hyprlock target.
|
|
||||||
input-field = {
|
input-field = {
|
||||||
size = "260, 52";
|
size = "260, 52";
|
||||||
rounding = 8;
|
rounding = 8;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user