Compare commits

2 Commits

Author SHA1 Message Date
Miguel Palhas 8ecaf614fc chore(yolo): update maestro input
Bumps maestro to 80a9399, which ships the Claude Code hook install;
syncs the resulting settings.json hooks upstream.
2026-08-24 23:36:20 +01:00
Miguel Palhas 29d77ac8b4 chore(yolo): update maestro input
Bumps maestro to ad2e700. Also syncs the model override in
claude-settings.json back from the live file, which had drifted.
2026-08-24 18:05:12 +01:00
13 changed files with 729 additions and 393 deletions
Generated
+4 -25
View File
@@ -22,11 +22,11 @@
},
"agent-skills": {
"locked": {
"lastModified": 1787576215,
"narHash": "sha256-x1ftVVEcq8p6phZEl4NC4F3RcPqpgx6wEMODMI5QHN8=",
"lastModified": 1787422509,
"narHash": "sha256-kl6hR1JdXC2H9y84QNJJUxxeYCMgz6toBPjJ2//xXKU=",
"ref": "refs/heads/main",
"rev": "0900e5fea1245c7d5e962e48a809ff7481c87f95",
"revCount": 99,
"rev": "0f131f00a42782d45955a2e94e86d8f2ae69c043",
"revCount": 79,
"type": "git",
"url": "https://git.naps.pt/yolo/agent-skills.git"
},
@@ -1052,26 +1052,6 @@
"type": "github"
}
},
"rev": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1787648458,
"narHash": "sha256-YO8lx85XorD2cnq5QNBvpj3JH60OrDV04GA3R2Oaih0=",
"ref": "refs/heads/main",
"rev": "88bf55a737046768adcea15b2475096519341494",
"revCount": 234,
"type": "git",
"url": "https://git.naps.pt/yolo/rev.git"
},
"original": {
"type": "git",
"url": "https://git.naps.pt/yolo/rev.git"
}
},
"root": {
"inputs": {
"agent-of-empires": "agent-of-empires",
@@ -1089,7 +1069,6 @@
"nixpkgs": "nixpkgs_7",
"nvchad-starter": "nvchad-starter",
"nvchad4nix": "nvchad4nix",
"rev": "rev",
"rose-pine-hyprcursor": "rose-pine-hyprcursor",
"sem": "sem",
"spicetify-nix": "spicetify-nix",
-7
View File
@@ -73,13 +73,6 @@
# Terminal-session orchestrator. Pins its own nixpkgs for the same reason
# ethui and agent-of-empires do — it is a verified Rust build.
maestro.url = "git+https://git.naps.pt/naps62/maestro.git";
# Always-on local code review server. Follows nixpkgs, unlike the Rust
# inputs above: it is a plain node bundle, and a second nixpkgs would put a
# second node 26 in the closure for nothing.
rev = {
url = "git+https://git.naps.pt/yolo/rev.git";
inputs.nixpkgs.follows = "nixpkgs";
};
nix-index-database = {
url = "github:nix-community/nix-index-database";
inputs.nixpkgs.follows = "nixpkgs";
+105
View File
@@ -0,0 +1,105 @@
{
config,
lib,
pkgs,
self,
...
}:
let
cfg = config.home.mutableFiles;
repoPath = config.home.mutableFilesRepoPath;
flakePrefix = self.outPath;
fileEntries = lib.attrsToList cfg;
toRepoPath = storePath: repoPath + lib.removePrefix flakePrefix (toString storePath);
checkScript = lib.concatMapStringsSep "\n" (
{ name, value }:
let
target = "${config.home.homeDirectory}/${name}";
storePath = value.source;
# toRepoPath only works for sources that live in the flake tree. A
# generated source is not under flakePrefix, so removePrefix is a no-op
# and the hint would print a /nix/store path to copy back onto.
originalPath =
if value.upstreamPath != null then "${repoPath}/${value.upstreamPath}" else toRepoPath value.source;
in
''
if [ -f "${target}" ] && ! ${lib.getExe' pkgs.diffutils "diff"} -q "${storePath}" "${target}" > /dev/null 2>&1; then
echo ""
echo "!! mutable file changed: ${name}"
echo " To bring changes upstream:"
echo " cp ${target} ${originalPath}"
echo ""
${lib.getExe' pkgs.diffutils "diff"} -u "${storePath}" "${target}" || true
_mutable_changed=1
fi
''
) fileEntries;
copyScript = lib.concatMapStringsSep "\n" (
{ name, value }:
let
target = "${config.home.homeDirectory}/${name}";
inherit (value) source;
dirName = builtins.dirOf target;
in
''
mkdir -p "${dirName}"
cp -f "${source}" "${target}"
chmod ${if value.executable then "755" else "644"} "${target}"
''
) fileEntries;
in
{
options.home.mutableFilesRepoPath = lib.mkOption {
type = lib.types.str;
description = "Absolute path to the nixos-config repo on disk.";
};
options.home.mutableFiles = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
source = lib.mkOption {
type = lib.types.path;
description = "Path to the source file.";
};
executable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether the file should be executable.";
};
upstreamPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "home/yolo/claude-settings.json";
description = ''
Repo-relative file to name in the "bring changes upstream" hint.
Required when `source` is generated rather than a file in the
flake tree, since the store path cannot be mapped back.
'';
};
};
}
);
default = { };
description = "Files to copy (not symlink) into the home directory, with change detection.";
};
config = lib.mkIf (cfg != { }) {
home.activation.mutableFiles = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
_mutable_changed=0
${checkScript}
if [ "$_mutable_changed" -eq 1 ]; then
echo ""
echo "!! Aborting: mutable files have been modified outside of nix."
echo " Bring the changes upstream first, then re-run."
exit 1
fi
${copyScript}
'';
};
}
+1 -3
View File
@@ -19,9 +19,6 @@
description = "--force-device-scale-factor value for Claude Desktop and T3 Code.";
};
# ~/.claude/settings.json stays unmanaged: Claude Code rewrites it itself
# (model pins, permission grants, plugin state), so any nix copy drifts within
# a session and every `nh home switch` then aborts on the diff.
config.home = {
packages = with pkgs; [
inputs.claude-code.packages.${pkgs.stdenv.hostPlatform.system}.default
@@ -50,5 +47,6 @@
};
};
mutableFiles.".claude/settings.json".source = ./settings.json;
};
}
+86
View File
@@ -0,0 +1,86 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Edit",
"Write",
"Bash(ls:*)",
"Bash(tree:*)",
"Bash(cat:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(find:*)",
"Bash(fd:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(wc:*)",
"Bash(sort:*)",
"Bash(uniq:*)",
"Bash(diff:*)",
"Bash(pwd:*)",
"Bash(which:*)",
"Bash(jq:*)",
"Bash(git:*)",
"Bash(gh:*)",
"Bash(sed:*)",
"Bash(cp:*)",
"Bash(chmod:*)",
"Bash(mkdir:*)",
"Bash(kitty @ set-tab-title:*)"
],
"additionalDirectories": [
"/home/naps62/projects",
"/home/naps62/ethui",
"/home/naps62/labs",
"/home/naps62/subvisual"
]
},
"model": "opus[1m]",
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh"
},
"enabledPlugins": {
"typescript-lsp@claude-plugins-official": true
},
"extraKnownMarketplaces": {
"impeccable": {
"source": {
"source": "github",
"repo": "pbakaus/impeccable"
}
}
},
"sandbox": {
"enabled": true,
"autoAllowBashIfSandboxed": false,
"allowedNetworkHosts": [
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"gist.github.com",
"release-assets.githubusercontent.com"
]
},
"tui": "fullscreen",
"voiceEnabled": true,
"defaultMode": "acceptEdits",
"feedbackSurveyState": {
"lastShownTime": 1754052643456
},
"mcpServers": {
"herd-mcp": {
"type": "http",
"url": "https://mcp.herd.eco/v1"
},
"linear": {
"type": "http",
"url": "https://mcp.linear.app/mcp"
},
"home-assistant": {
"type": "http",
"url": "https://ha-mcp.n62.casa/mcp"
}
},
"agentPushNotifEnabled": true
}
+5
View File
@@ -5,6 +5,7 @@
}:
{
imports = [
../features/mutable-file.nix
./zsh.nix
./nix.nix
./neovim
@@ -27,6 +28,10 @@
};
home = {
# mkDefault: hosts whose clone lives elsewhere (yolo, under ~/tea) override
# this with a plain assignment.
mutableFilesRepoPath = lib.mkDefault "${config.home.homeDirectory}/projects/nixos-config";
username = lib.mkDefault "naps62";
homeDirectory = lib.mkDefault "/home/${config.home.username}";
stateVersion = lib.mkDefault "24.05";
@@ -1,198 +0,0 @@
{
config,
lib,
pkgs,
...
}:
# nix-autodeploy: one webhook endpoint that turns "a repo I own pushed to main"
# into "that flake input is bumped, committed, and (optionally) applied".
#
# Replaces per-app deploy webhooks that rebuilt from a checkout on the box. The
# apps are flake inputs now, so deploying one is a lock bump plus a generation
# switch — the same operation for every app, hence one service instead of N.
let
cfg = config.services.nixAutodeploy;
# A user unit inherits almost no PATH, and the deploy shells out to git (with
# the gitea credential helper), nix and nh.
profilePath = lib.concatStringsSep ":" [
"%h/.local/bin"
"%h/.nix-profile/bin"
"/etc/profiles/per-user/%u/bin"
"/run/current-system/sw/bin"
];
deploy = pkgs.writeShellApplication {
name = "nix-autodeploy-deploy";
runtimeInputs = [
pkgs.git
pkgs.nix
pkgs.nh
pkgs.curl
pkgs.jq
pkgs.util-linux
];
text = ''
# usage: nix-autodeploy-deploy <flake-input> <apply|notify>
input=$1
mode=$2
flake=${lib.escapeShellArg cfg.flake}
topic=${lib.escapeShellArg cfg.ntfy.topic}
ntfy_url=${lib.escapeShellArg cfg.ntfy.url}
notify() {
[ -n "''${NTFY_TOKEN:-}" ] || return 0
curl -fsS -X POST "$ntfy_url/$topic" \
-H "Authorization: Bearer $NTFY_TOKEN" \
-H "Title: $1" -d "$2" > /dev/null || true
}
# Serialize: two pushes landing together would otherwise race on the same
# working tree and the same flake.lock.
exec 9> "''${XDG_RUNTIME_DIR:-/tmp}/nix-autodeploy.lock"
flock 9
cd "$flake"
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$branch" != main ]; then
notify "autodeploy skipped ($input)" "checkout is on $branch, not main"
exit 0
fi
if ! git diff --quiet || ! git diff --cached --quiet; then
notify "autodeploy skipped ($input)" "working tree is dirty"
exit 0
fi
git fetch --quiet origin main
git merge --ff-only --quiet origin/main
nix flake update "$input"
if git diff --quiet -- flake.lock; then
echo "autodeploy: $input already at the pushed revision"
exit 0
fi
rev=$(nix flake metadata --json |
jq -r --arg i "$input" '.locks.nodes[$i].locked.rev[0:7]')
git commit --quiet -m "chore(flake): bump $input to $rev" -- flake.lock
git push --quiet origin main
if [ "$mode" != apply ]; then
notify "$input bumped to $rev" "Not applied run 'nh home switch' when convenient."
exit 0
fi
if nh home switch "$flake"; then
notify "$input deployed" "Bumped to $rev and switched."
else
notify "$input FAILED to apply" "Lock is at $rev on main; the switch failed. See journalctl --user -u run-*."
exit 1
fi
'';
};
listener = pkgs.writers.writePython3Bin "nix-autodeploy" {
# Only line length: http.server's do_GET/do_POST spelling is already
# excused inline.
flakeIgnore = [ "E501" ];
} (builtins.readFile ./listener.py);
repoFile = (pkgs.formats.json { }).generate "nix-autodeploy-repos.json" (
lib.mapAttrs (_: r: { inherit (r) input apply; }) cfg.repos
);
in
{
options.services.nixAutodeploy = {
enable = lib.mkEnableOption "the forge-webhook listener that bumps and applies flake inputs";
port = lib.mkOption {
type = lib.types.port;
default = 7375;
description = "Port the listener binds on 0.0.0.0. There is no auth beyond the webhook HMAC, so only expose it through the reverse proxy.";
};
flake = lib.mkOption {
type = lib.types.str;
example = "/home/naps62/tea/nixos-config";
description = "Absolute path to the nixos-config checkout whose flake.lock gets bumped. Must be on main and clean, or the deploy skips.";
};
environmentFile = lib.mkOption {
type = lib.types.str;
example = "%h/.config/nix-autodeploy/env";
description = ''
File holding `NIX_AUTODEPLOY_SECRET` (the webhook HMAC secret, shared
with every repo below) and `NTFY_TOKEN`. Not in the store these are
secrets.
'';
};
ntfy = {
url = lib.mkOption {
type = lib.types.str;
default = "https://ntfy.home.naps.pt";
description = "Base URL of the ntfy server deploy results are posted to.";
};
topic = lib.mkOption {
type = lib.types.str;
default = "nix-autodeploy";
description = "ntfy topic for deploy results.";
};
};
repos = lib.mkOption {
default = { };
description = "Forge repositories to listen for, keyed by `<owner>/<repo>` exactly as the webhook payload spells it.";
example = lib.literalExpression ''
{ "yolo/rev" = { input = "rev"; }; }
'';
type = lib.types.attrsOf (
lib.types.submodule {
options = {
input = lib.mkOption {
type = lib.types.str;
description = "Name of the flake input in this repo's flake.nix that tracks that repository.";
};
apply = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to run `nh home switch` after the bump. Set false for an
app whose restart disrupts a live session the lock is still
bumped and pushed, and the ntfy message says it is waiting.
'';
};
};
}
);
};
};
config = lib.mkIf cfg.enable {
systemd.user.services.nix-autodeploy = {
Unit = {
Description = "nix-autodeploy forge webhooks bump and apply flake inputs";
After = [ "network.target" ];
# Same restart-budget trap every other always-on unit here avoids: at
# RestartSec=2 a fast-crashing listener would park in `failed`.
StartLimitIntervalSec = 0;
};
Service = {
Type = "simple";
ExecStart = lib.getExe listener;
EnvironmentFile = cfg.environmentFile;
Environment = [
"PATH=${profilePath}"
"NIX_AUTODEPLOY_PORT=${toString cfg.port}"
"NIX_AUTODEPLOY_DEPLOY_BIN=${lib.getExe deploy}"
"NIX_AUTODEPLOY_ENV_FILE=${cfg.environmentFile}"
"NIX_AUTODEPLOY_REPOS_FILE=${repoFile}"
];
Restart = "always";
RestartSec = 2;
};
Install.WantedBy = [ "default.target" ];
};
};
}
@@ -1,104 +0,0 @@
"""Forge webhook listener that turns a push into a flake-input bump.
Verifies the HMAC a Gitea (or GitHub) webhook signs the body with, then hands
the actual work to a transient systemd unit. Nothing is done in-process: the
deploy runs `nh home switch`, which restarts every unit home-manager owns —
including this listener — so the job has to outlive it.
"""
import hashlib
import hmac
import json
import os
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
SECRET = os.environ.get("NIX_AUTODEPLOY_SECRET", "").encode()
PORT = int(os.environ.get("NIX_AUTODEPLOY_PORT", "7375"))
DEPLOY = os.environ["NIX_AUTODEPLOY_DEPLOY_BIN"]
# {"<owner>/<repo>": {"input": "rev", "apply": true}, ...}. Passed as a file,
# not a variable: systemd's Environment= strips the quotes out of inline JSON.
with open(os.environ["NIX_AUTODEPLOY_REPOS_FILE"]) as fh:
REPOS = json.load(fh)
ENV_FILE = os.environ.get("NIX_AUTODEPLOY_ENV_FILE", "")
MAX_BODY = 1 << 20
if not SECRET:
sys.exit("NIX_AUTODEPLOY_SECRET is not set")
def signature_ok(body: bytes, headers) -> bool:
expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
# Gitea sends the bare hex digest; GitHub prefixes it with "sha256=".
for name in ("X-Gitea-Signature", "X-Hub-Signature-256"):
got = headers.get(name)
if got and hmac.compare_digest(got.removeprefix("sha256="), expected):
return True
return False
def spawn(repo: str, entry: dict) -> None:
cmd = [
"systemd-run",
"--user",
"--collect",
f"--description=nix-autodeploy: {repo}",
# PATH is not inherited by a transient unit, and the deploy shells out
# to git, nix and the gitea credential helper.
f"--setenv=PATH={os.environ['PATH']}",
]
if ENV_FILE:
cmd.append(f"--property=EnvironmentFile={ENV_FILE}")
cmd += [DEPLOY, entry["input"], "apply" if entry.get("apply", True) else "notify"]
subprocess.run(cmd, check=True)
class Handler(BaseHTTPRequestHandler):
def reply(self, code: int, text: str) -> None:
payload = text.encode()
self.send_response(code)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's spelling
if self.path == "/health":
self.reply(200, "ok\n")
else:
self.reply(404, "no\n")
def do_POST(self) -> None: # noqa: N802
length = int(self.headers.get("Content-Length", "0"))
if length > MAX_BODY:
return self.reply(413, "body too large\n")
body = self.rfile.read(length)
if not signature_ok(body, self.headers):
return self.reply(401, "bad signature\n")
try:
event = json.loads(body)
except json.JSONDecodeError:
return self.reply(400, "bad json\n")
ref = event.get("ref")
repo = (event.get("repository") or {}).get("full_name")
if ref != "refs/heads/main":
return self.reply(200, f"ignored ref {ref}\n")
entry = REPOS.get(repo)
if entry is None:
return self.reply(200, f"ignored repo {repo}\n")
spawn(repo, entry)
self.reply(202, f"deploying {entry['input']}\n")
def log_message(self, fmt: str, *args) -> None:
# Journal already timestamps; the default format prepends its own.
sys.stderr.write(f"{self.address_string()} {fmt % args}\n")
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
-7
View File
@@ -1,7 +0,0 @@
{ inputs, ... }:
# rev: the always-on code review server. Module only — no `enable` here. It
# discovers every repo under its roots and serves them without auth, so only a
# box that is already a trusted single-user machine should run it.
{
imports = [ inputs.rev.homeManagerModules.default ];
}
+91
View File
@@ -0,0 +1,91 @@
default_profile = "default"
[acp]
allow_agent_install = false
allowed_agents = []
auto_stop_idle_secs = 0
compaction_reminder = false
compaction_reminder_percent = 75
default_agent = "claude"
max_concurrent_workers = 5
node_path = ""
offer_structured_in_new_session = true
rate_limit_auto_resume = false
replay_events = 0
restrict_agents = false
show_tool_durations = true
silent_orphan_grace_secs = 120
[hooks]
[host_hooks]
[logging]
default_level = "info"
file_path = "debug.log"
keep_count = 5
max_size_mib = 50
output = "file"
rotation = "size"
show_spans = false
[logging.targets]
[session]
agent_status_hooks = true
auto_resume_on_restart = true
auto_stop_idle_secs = 0
click_action = "live_send"
confirm_before_quit = false
confirm_delete = true
default_attach_mode = "tmux"
delete_to_trash = false
favorites_first = true
inherit_host_environment = false
live_send_exit_chord = "C-q"
live_send_leader = "C-b"
live_send_on_view_switch = false
merge_hooks_into_selected_agent = true
mouse_capture = true
opencode_preassign_session_id = false
prevent_sleep_idle_grace_minutes = 15
prevent_sleep_when_active = false
restart_wake_message = "wake up: pick up what you were doing"
row_tag = "none"
show_session_colors = true
smart_rename = true
smart_rename_agent = ""
strict_hotkeys = false
tie_workdir_to_name = true
trash_retention_days = 30
unread_indicator = true
yolo_mode_default = true
[session.agent_detect_as]
synclaude = "claude"
[session.custom_agents]
synclaude = "synclaude"
[status_hooks]
enabled = false
[theme]
color_mode = "truecolor"
idle_decay_minutes = 0
name = "zinc"
[tmux]
clipboard = "auto"
mouse = "auto"
status_bar = "auto"
vt_live = true
[worktree]
auto_cleanup = true
bare_repo_path_template = "./{branch}"
delete_branch_on_cleanup = false
enabled = true
init_submodules = true
path_template = "../{repo-name}-worktrees/{branch}"
workspace_path_template = "../{branch}-workspace-{session-id}"
+341
View File
@@ -0,0 +1,341 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"agentPushNotifEnabled": true,
"autoCompactEnabled": true,
"defaultMode": "acceptEdits",
"editorMode": "vim",
"enabledPlugins": {
"building@subvisual": true,
"caveman@caveman": true,
"rust-analyzer-lsp@claude-plugins-official": false,
"typescript-lsp@claude-plugins-official": true
},
"extraKnownMarketplaces": {
"caveman": {
"source": {
"repo": "JuliusBrussee/caveman",
"source": "github"
}
},
"impeccable": {
"source": {
"repo": "pbakaus/impeccable",
"source": "github"
}
},
"subvisual": {
"source": {
"repo": "subvisual/harness",
"source": "github"
}
},
"superpowers-marketplace": {
"source": {
"repo": "obra/superpowers-marketplace",
"source": "github"
}
}
},
"feedbackSurveyState": {
"lastShownTime": 1754052643456
},
"hooks": {
"ElicitationResult": [
{
"hooks": [
{
"command": "sh -c 'unset IFS; set -f; umask 077; [ -n \"$AOE_INSTANCE_ID\" ] || exit 0; case \"$AOE_INSTANCE_ID\" in *[!0-9a-zA-Z_-]*) exit 0 ;; esac; B=/tmp/aoe-hooks-1000; mkdir -p \"$B\" 2>/dev/null || exit 0; LS=$(LC_ALL=C ls -ldn \"$B\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; ME=$(id -u 2>/dev/null) || exit 0; [ \"$3\" = \"$ME\" ] || exit 0; D=\"$B/$AOE_INSTANCE_ID\"; mkdir -p \"$D\" 2>/dev/null; LS=$(LC_ALL=C ls -ldn \"$D\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; [ \"$3\" = \"$ME\" ] || exit 0; printf running > \"$D/status\" 2>/dev/null; exit 0 # aoe-hooks'",
"type": "command"
}
]
}
],
"Notification": [
{
"hooks": [
{
"command": "~/.claude/hooks/tmux-attention.sh",
"type": "command"
}
]
},
{
"hooks": [
{
"command": "sh -c 'unset IFS; set -f; umask 077; [ -n \"$AOE_INSTANCE_ID\" ] || exit 0; case \"$AOE_INSTANCE_ID\" in *[!0-9a-zA-Z_-]*) exit 0 ;; esac; B=/tmp/aoe-hooks-1000; mkdir -p \"$B\" 2>/dev/null || exit 0; LS=$(LC_ALL=C ls -ldn \"$B\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; ME=$(id -u 2>/dev/null) || exit 0; [ \"$3\" = \"$ME\" ] || exit 0; D=\"$B/$AOE_INSTANCE_ID\"; mkdir -p \"$D\" 2>/dev/null; LS=$(LC_ALL=C ls -ldn \"$D\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; [ \"$3\" = \"$ME\" ] || exit 0; printf waiting > \"$D/status\" 2>/dev/null; exit 0 # aoe-hooks'",
"type": "command"
}
],
"matcher": "permission_prompt|elicitation_dialog|agent_needs_input"
},
{
"hooks": [
{
"command": "sh -c 'unset IFS; set -f; umask 077; [ -n \"$AOE_INSTANCE_ID\" ] || exit 0; case \"$AOE_INSTANCE_ID\" in *[!0-9a-zA-Z_-]*) exit 0 ;; esac; B=/tmp/aoe-hooks-1000; mkdir -p \"$B\" 2>/dev/null || exit 0; LS=$(LC_ALL=C ls -ldn \"$B\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; ME=$(id -u 2>/dev/null) || exit 0; [ \"$3\" = \"$ME\" ] || exit 0; D=\"$B/$AOE_INSTANCE_ID\"; mkdir -p \"$D\" 2>/dev/null; LS=$(LC_ALL=C ls -ldn \"$D\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; [ \"$3\" = \"$ME\" ] || exit 0; printf idle > \"$D/status\" 2>/dev/null; exit 0 # aoe-hooks'",
"type": "command"
}
],
"matcher": "idle_prompt|agent_completed"
},
{
"hooks": [
{
"command": "/home/naps62/.local/share/maestro/maestro-claude-hook.sh AwaitingInput",
"type": "command"
}
]
}
],
"PostToolUse": [
{
"hooks": [
{
"command": "~/.claude/hooks/comment-lint.py",
"type": "command"
}
],
"matcher": "Write|Edit|MultiEdit"
},
{
"hooks": [
{
"command": "sh -c 'unset IFS; set -f; umask 077; [ -n \"$AOE_INSTANCE_ID\" ] || exit 0; case \"$AOE_INSTANCE_ID\" in *[!0-9a-zA-Z_-]*) exit 0 ;; esac; B=/tmp/aoe-hooks-1000; mkdir -p \"$B\" 2>/dev/null || exit 0; LS=$(LC_ALL=C ls -ldn \"$B\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; ME=$(id -u 2>/dev/null) || exit 0; [ \"$3\" = \"$ME\" ] || exit 0; D=\"$B/$AOE_INSTANCE_ID\"; mkdir -p \"$D\" 2>/dev/null; LS=$(LC_ALL=C ls -ldn \"$D\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; [ \"$3\" = \"$ME\" ] || exit 0; printf running > \"$D/status\" 2>/dev/null; exit 0 # aoe-hooks'",
"type": "command"
}
],
"matcher": "AskUserQuestion"
},
{
"hooks": [
{
"command": "/home/naps62/.local/share/maestro/maestro-claude-hook.sh Active",
"type": "command"
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"command": "~/.claude/hooks/comms-lint.py",
"type": "command"
}
],
"matcher": "Bash"
},
{
"hooks": [
{
"command": "~/.claude/hooks/tmux-reset.sh",
"type": "command"
}
],
"matcher": ""
},
{
"hooks": [
{
"command": "rtk hook claude",
"type": "command"
}
],
"matcher": "Bash"
},
{
"hooks": [
{
"command": "sh -c 'for H in ~/.claude/hooks/secret-guard.py /home/naps62/tea/agent-skills/hooks/secret-guard.py /home/naps62/tea/agent-skills/worktrees/week-review-2026-w34/hooks/secret-guard.py; do [ -e \"$H\" ] && exec \"$H\"; done; exit 0'",
"type": "command"
}
],
"matcher": "Bash|Write|Edit|MultiEdit|NotebookEdit"
},
{
"hooks": [
{
"command": "sh -c 'unset IFS; set -f; umask 077; [ -n \"$AOE_INSTANCE_ID\" ] || exit 0; case \"$AOE_INSTANCE_ID\" in *[!0-9a-zA-Z_-]*) exit 0 ;; esac; B=/tmp/aoe-hooks-1000; mkdir -p \"$B\" 2>/dev/null || exit 0; LS=$(LC_ALL=C ls -ldn \"$B\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; ME=$(id -u 2>/dev/null) || exit 0; [ \"$3\" = \"$ME\" ] || exit 0; D=\"$B/$AOE_INSTANCE_ID\"; mkdir -p \"$D\" 2>/dev/null; LS=$(LC_ALL=C ls -ldn \"$D\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; [ \"$3\" = \"$ME\" ] || exit 0; IN=$(cat 2>/dev/null); S=running; case \"$IN\" in *\\\"tool_name\\\":\\\"AskUserQuestion\\\"*) S=waiting ;; esac; printf %s \"$S\" > \"$D/status\" 2>/dev/null; exit 0 # aoe-hooks'",
"type": "command"
}
]
},
{
"hooks": [
{
"command": "/home/naps62/.local/share/maestro/maestro-claude-hook.sh Active",
"type": "command"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"command": "~/.claude/hooks/git-autoupdate.sh",
"type": "command"
}
],
"matcher": "startup|resume|clear"
},
{
"hooks": [
{
"command": "python3 ~/.claude/scripts/aoe-register-remote.py",
"type": "command"
}
]
},
{
"hooks": [
{
"command": "/home/naps62/tea/rev/scripts/rev-hook-session-start.sh",
"type": "command"
}
]
},
{
"hooks": [
{
"command": "sh -c '[ -n \"$AOE_INSTANCE_ID\" ] || exit 0; command -v aoe >/dev/null 2>&1 || exit 0; aoe __extract-session-id 2>/dev/null; exit 0 # aoe-hooks'",
"type": "command"
}
]
}
],
"Stop": [
{
"hooks": [
{
"command": "/home/naps62/tea/rev/scripts/rev-hook-stop.sh",
"type": "command"
}
]
},
{
"hooks": [
{
"command": "sh -c 'unset IFS; set -f; umask 077; [ -n \"$AOE_INSTANCE_ID\" ] || exit 0; case \"$AOE_INSTANCE_ID\" in *[!0-9a-zA-Z_-]*) exit 0 ;; esac; B=/tmp/aoe-hooks-1000; mkdir -p \"$B\" 2>/dev/null || exit 0; LS=$(LC_ALL=C ls -ldn \"$B\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; ME=$(id -u 2>/dev/null) || exit 0; [ \"$3\" = \"$ME\" ] || exit 0; D=\"$B/$AOE_INSTANCE_ID\"; mkdir -p \"$D\" 2>/dev/null; LS=$(LC_ALL=C ls -ldn \"$D\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; [ \"$3\" = \"$ME\" ] || exit 0; printf idle > \"$D/status\" 2>/dev/null; exit 0 # aoe-hooks'",
"type": "command"
}
]
},
{
"hooks": [
{
"command": "/home/naps62/.local/share/maestro/maestro-claude-hook.sh AwaitingInput",
"type": "command"
}
]
}
],
"StopFailure": [
{
"hooks": [
{
"command": "sh -c 'unset IFS; set -f; umask 077; [ -n \"$AOE_INSTANCE_ID\" ] || exit 0; case \"$AOE_INSTANCE_ID\" in *[!0-9a-zA-Z_-]*) exit 0 ;; esac; B=/tmp/aoe-hooks-1000; mkdir -p \"$B\" 2>/dev/null || exit 0; LS=$(LC_ALL=C ls -ldn \"$B\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; ME=$(id -u 2>/dev/null) || exit 0; [ \"$3\" = \"$ME\" ] || exit 0; D=\"$B/$AOE_INSTANCE_ID\"; mkdir -p \"$D\" 2>/dev/null; LS=$(LC_ALL=C ls -ldn \"$D\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; [ \"$3\" = \"$ME\" ] || exit 0; printf idle > \"$D/status\" 2>/dev/null; exit 0 # aoe-hooks'",
"type": "command"
}
]
},
{
"hooks": [
{
"command": "/home/naps62/.local/share/maestro/maestro-claude-hook.sh Error",
"type": "command"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"command": "sh -c '[ -n \"$AOE_INSTANCE_ID\" ] || exit 0; command -v aoe >/dev/null 2>&1 || exit 0; aoe __extract-session-id 2>/dev/null; exit 0 # aoe-hooks'",
"type": "command"
},
{
"command": "sh -c 'unset IFS; set -f; umask 077; [ -n \"$AOE_INSTANCE_ID\" ] || exit 0; case \"$AOE_INSTANCE_ID\" in *[!0-9a-zA-Z_-]*) exit 0 ;; esac; B=/tmp/aoe-hooks-1000; mkdir -p \"$B\" 2>/dev/null || exit 0; LS=$(LC_ALL=C ls -ldn \"$B\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; ME=$(id -u 2>/dev/null) || exit 0; [ \"$3\" = \"$ME\" ] || exit 0; D=\"$B/$AOE_INSTANCE_ID\"; mkdir -p \"$D\" 2>/dev/null; LS=$(LC_ALL=C ls -ldn \"$D\" 2>/dev/null) || exit 0; set -- $LS; M=\"$1\"; case \"$M\" in drwx------|drwx------.|drwx------+|drwx------@) ;; *) exit 0 ;; esac; [ \"$3\" = \"$ME\" ] || exit 0; printf running > \"$D/status\" 2>/dev/null; exit 0 # aoe-hooks'",
"type": "command"
}
]
}
]
},
"inputNeededNotifEnabled": true,
"mcpServers": {
"herd-mcp": {
"type": "http",
"url": "https://mcp.herd.eco/v1"
},
"home-assistant": {
"type": "http",
"url": "https://ha-mcp.n62.casa/mcp"
},
"linear": {
"type": "http",
"url": "https://mcp.linear.app/mcp"
},
"playwright": {
"args": [
"-y",
"@playwright/mcp@latest"
],
"command": "npx",
"type": "stdio"
}
},
"model": "opus[1m]",
"permissions": {
"additionalDirectories": [
"/home/naps62/projects",
"/home/naps62/ethui",
"/home/naps62/labs",
"/home/naps62/subvisual"
],
"allow": [
"Bash",
"Edit",
"Write",
"NotebookEdit",
"WebFetch",
"WebSearch"
],
"defaultMode": "auto",
"deny": [
"Read(~/.ssh/**)",
"Read(*.pem)",
"Read(*.key)",
"mcp__dokploy__application-one",
"mcp__dokploy__mounts-one",
"mcp__dokploy__schedule-create",
"mcp__dokploy__schedule-update",
"mcp__dokploy__schedule-runManually"
]
},
"preferredNotifChannel": "auto",
"remoteControlAtStartup": false,
"sandbox": {
"allowedNetworkHosts": [
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"gist.github.com",
"release-assets.githubusercontent.com"
],
"autoAllowBashIfSandboxed": false,
"enabled": false
},
"skipDangerousModePermissionPrompt": true,
"statusLine": {
"command": "~/.claude/statusline.sh",
"type": "command"
},
"theme": "dark",
"tui": "fullscreen",
"verbose": false,
"voiceEnabled": true,
"worktree": {
"baseRef": "fresh"
}
}
+38 -10
View File
@@ -4,9 +4,19 @@
...
}:
let
# ./opencode.json holds only yolo's overrides; everything else is inherited
# so agents, commands and skills keep coming from common. Attrsets merge
# key-by-key, lists are replaced whole.
claudeSettings = "home/yolo/claude-settings.json";
# ./claude-settings.json holds only what yolo overrides; everything else is
# inherited so common changes reach this host. Attrsets merge key-by-key,
# lists are replaced whole (permissions.allow is yolo's, not a union).
mergedClaudeSettings = (pkgs.formats.json { }).generate "claude-settings.json" (
lib.recursiveUpdate (lib.importJSON ../common/programs/claude/settings.json) (
lib.importJSON ./claude-settings.json
)
);
# Same merge, same reason: ./opencode.json holds only yolo's overrides so
# agents, commands and skills keep coming from common.
mergedOpencodeConfig = (pkgs.formats.json { }).generate "opencode.json" (
lib.recursiveUpdate (lib.importJSON ../common/programs/opencode/opencode.json) (
lib.importJSON ./opencode.json
@@ -22,16 +32,14 @@ in
../common/programs/gpg.nix
../common/programs/aoe
../common/programs/maestro
../common/programs/rev
../common/programs/nix-autodeploy
../common/features/xdg.nix
./monitors.nix
./services.nix
./ssh.nix
];
# Host-local: bash goes from "ask" to "allow" so unattended opencode sessions
# stop stalling on every git and grep.
# Host-local, like the aoe config below: bash goes from "ask" to
# "allow" so unattended opencode sessions stop stalling on every git and grep.
# It also drops the prompt on branches under review, which is the tradeoff.
xdg.configFile."opencode/opencode.json".source = lib.mkForce mergedOpencodeConfig;
@@ -46,9 +54,29 @@ in
light = "Bibata-Modern-Amber";
};
# Headless browser driver the agent tooling shells out to. Was a global npm
# install on the ubuntu box.
home.packages = [ pkgs.agent-browser ];
home = {
# Headless browser driver the agent tooling shells out to. Was a global npm
# install on the ubuntu box.
packages = [ pkgs.agent-browser ];
# Both default to ~/projects/nixos-config in common/programs; this clone
# lives under ~/tea. nh.flake sets NH_FLAKE, so without it `nh home switch`
# with no argument resolves to a path that does not exist.
mutableFilesRepoPath = "/home/naps62/tea/nixos-config";
mutableFiles = {
# Host-local, not shared: this sets yolo_mode_default = true, which starts
# aoe sessions with permission checks skipped. Only correct on this box.
".config/agent-of-empires/config.toml".source = ./aoe-config.toml;
# Likewise host-local: carries skipDangerousModePermissionPrompt and the
# rev hook paths, neither of which belong on a workstation.
".claude/settings.json" = {
source = lib.mkForce mergedClaudeSettings;
upstreamPath = claudeSettings;
};
};
};
# This is the one box that runs the agent-skills units; each starts a session,
# so a second machine enabling them would run the same job twice.
+58 -39
View File
@@ -3,11 +3,12 @@
inputs,
...
}:
# The user services this box exists to run.
# The user services this box exists to run, ported from hand-written units in
# ~/.config/systemd/user on the Ubuntu machine.
#
# maestro and rev come from their own flakes, so nix owns the build as well as
# the unit. aoe-web is still the odd one out: its unit is defined here and the
# binary comes from the flake input.
# NOT self-contained: every ExecStart under ~/.bun or ~/.local/bin is an
# imperatively-installed binary, and the WorkingDirectories are clones of
# separate repos. Nix owns the unit definitions here, nothing more.
let
# A user unit gets almost no PATH by default; these are the profile dirs the
# original units got for free from the system PATH on Ubuntu.
@@ -18,17 +19,16 @@ let
aoe = inputs.agent-of-empires.packages.${pkgs.system}.aoe-with-web;
in
{
# sem is here as well as on rev's unit: the shell uses it directly too.
home.packages = [
sem
pkgs.bun
# ACP adapter aoe's structured (web) sessions spawn as `claude-agent-acp`.
pkgs.claude-agent-acp
];
# maestro's and rev's units come from their flake modules, not from the
# hand-written set below. 8081, not 8080: aoe-web already has that port on
# this host. There is no auth layer, so 0.0.0.0 is only safe behind the LAN
# perimeter.
# maestro's own units come from its flake module, not from the hand-written
# set below. 8081, not 8080: aoe-web already has that port on this host.
# There is no auth layer, so 0.0.0.0 is only safe behind the LAN perimeter.
services.maestro = {
enable = true;
web.enable = true;
@@ -41,41 +41,60 @@ in
};
};
# Everything under ~, three levels deep — the worktrees live at
# ~/<area>/<repo>/worktrees/<name>. sem gives entity-level diffs; without it
# rev falls back to line diffs.
services.rev = {
enable = true;
roots = [ "%h" ];
depth = 3;
semBin = "${sem}/bin/sem";
};
# One endpoint for every repo this config pins. rev and agent-skills apply
# straight away; maestro only bumps the lock and notifies, because switching
# restarts the daemon that owns every interactive shell on this box.
services.nixAutodeploy = {
enable = true;
flake = "/home/naps62/tea/nixos-config";
environmentFile = "%h/.config/nix-autodeploy/env";
repos = {
"yolo/rev".input = "rev";
"yolo/agent-skills".input = "agent-skills";
"naps62/maestro" = {
input = "maestro";
apply = false;
};
};
};
systemd.user.services = {
rev = {
Unit = {
Description = "rev always-on local code review server";
After = [ "network.target" ];
# MUST stay 0: at RestartSec=2 a fast-crashing rev burns the default
# 5-starts-per-10s budget, and systemd parks the unit in `failed` until
# a manual `systemctl --user reset-failed`.
StartLimitIntervalSec = 0;
};
Service = {
Type = "simple";
WorkingDirectory = "%h/tea/rev";
# nodejs_26, not pkgs.nodejs: rev's package.json sets engines >=26 and
# the nixpkgs default is 24.
ExecStart = "${pkgs.nodejs_26}/bin/node server/index.ts";
Environment = [
"NODE_ENV=production"
"REV_ROOTS=%h"
"REV_DEPTH=3"
"REV_SEM_BIN=${sem}/bin/sem"
"PATH=${toolPath}"
];
Restart = "always";
RestartSec = 2;
};
Install.WantedBy = [ "default.target" ];
};
rev-deploy = {
Unit = {
Description = "rev-deploy Gitea webhook listener that deploys rev on push to main";
After = [ "network.target" ];
# Same restart-budget trap as `rev` above.
StartLimitIntervalSec = 0;
};
Service = {
Type = "simple";
WorkingDirectory = "%h/tea/rev";
ExecStart = "${pkgs.bun}/bin/bun scripts/deploy-webhook.ts";
EnvironmentFile = "%h/.config/rev/deploy.env";
# Unit files are home-manager symlinks; deploy.sh must not rewrite them.
Environment = [ "PATH=${toolPath}" "REV_SKIP_UNIT_INSTALL=1" ];
Restart = "always";
RestartSec = 2;
};
Install.WantedBy = [ "default.target" ];
};
aoe-web = {
Unit = {
Description = "aoe serve Agent of Empires web dashboard";
After = [ "network.target" ];
# MUST stay 0: at RestartSec=2 a fast-crashing aoe burns the default
# 5-starts-per-10s budget, and systemd parks the unit in `failed` until
# a manual `systemctl --user reset-failed`.
# Same restart-budget trap as `rev` above.
StartLimitIntervalSec = 0;
};
Service = {