All checks were successful
CI / check (push) Successful in 3m24s
Complete the statusline optimization by adding cache writes to all output functions. The existing patch added cache functions and cache reads, but never wrote to the cache. Changes: - Add early-return for detached sessions (return static "○ |") - Add cache read check for attached sessions - Add setStatusLineCache() calls in all 5 output functions: - runWorkerStatusLine - runMayorStatusLine - runDeaconStatusLine - runWitnessStatusLine - runRefineryStatusLine This should reduce Dolt CPU from ~70% to ~20% when agents are idle, as tmux status lines will use cached results instead of spawning beads queries every 5 seconds. Testing: Run `nix switch` then monitor Dolt CPU with `top` Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
464 lines
16 KiB
Nix
464 lines
16 KiB
Nix
{ config, lib, pkgs, globalInputs, system, ... }:
|
|
|
|
with lib;
|
|
|
|
let
|
|
cfg = config.home.roles.development;
|
|
|
|
# FIXME: Temporary override for upstream beads vendorHash mismatch
|
|
# Remove after upstream fix: https://github.com/steveyegge/beads/issues/XXX
|
|
beadsPackage = globalInputs.beads.packages.${system}.default.overrideAttrs (old: {
|
|
vendorHash = "sha256-YU+bRLVlWtHzJ1QPzcKJ70f+ynp8lMoIeFlm+29BNPE=";
|
|
});
|
|
|
|
# Gastown - multi-agent workspace manager (no upstream flake.nix yet)
|
|
# Source is tracked via flake input for renovate updates
|
|
gastownRev = builtins.substring 0 8 (globalInputs.gastown.rev or "unknown");
|
|
gastownPackage = pkgs.buildGoModule {
|
|
pname = "gastown";
|
|
version = "unstable-${gastownRev}";
|
|
src = globalInputs.gastown;
|
|
vendorHash = "sha256-ripY9vrYgVW8bngAyMLh0LkU/Xx1UUaLgmAA7/EmWQU=";
|
|
subPackages = [ "cmd/gt" ];
|
|
doCheck = false;
|
|
|
|
# Must match ldflags from gastown Makefile - BuiltProperly=1 is required
|
|
# or gt will error with "This binary was built with 'go build' directly"
|
|
ldflags = [
|
|
"-X github.com/steveyegge/gastown/internal/cmd.Version=${gastownRev}"
|
|
"-X github.com/steveyegge/gastown/internal/cmd.Commit=${gastownRev}"
|
|
"-X github.com/steveyegge/gastown/internal/cmd.BuildTime=nix-build"
|
|
"-X github.com/steveyegge/gastown/internal/cmd.BuiltProperly=1"
|
|
];
|
|
|
|
# Bug fixes not yet merged upstream
|
|
postPatch = ''
|
|
# Fix validateRecipient bug: normalize addresses before comparison
|
|
# See: https://github.com/steveyegge/gastown/issues/TBD
|
|
substituteInPlace internal/mail/router.go \
|
|
--replace-fail \
|
|
'if agentBeadToAddress(agent) == identity {' \
|
|
'if AddressToIdentity(agentBeadToAddress(agent)) == AddressToIdentity(identity) {'
|
|
|
|
# Fix agentBeadToAddress to use title field for hq- prefixed beads
|
|
# Title should contain the address (e.g., "java/crew/americano")
|
|
substituteInPlace internal/mail/router.go \
|
|
--replace-fail \
|
|
'return parseAgentAddressFromDescription(bead.Description)' \
|
|
'if bead.Title != "" && strings.Contains(bead.Title, "/") { return bead.Title }; return parseAgentAddressFromDescription(bead.Description)'
|
|
|
|
# Fix agentBeadToAddress to handle rig-specific prefixes (j-, sc-, etc.)
|
|
# Bead IDs like j-java-crew-americano should map to java/crew/americano
|
|
substituteInPlace internal/mail/router.go \
|
|
--replace-fail \
|
|
'// Handle gt- prefixed IDs (legacy format)
|
|
if !strings.HasPrefix(id, "gt-") {
|
|
return "" // Not a valid agent bead ID
|
|
}' \
|
|
'// Handle rig-specific prefixes: <prefix>-<rig>-<role>-<name>
|
|
// Examples: j-java-crew-americano -> java/crew/americano
|
|
idParts := strings.Split(id, "-")
|
|
if len(idParts) >= 3 {
|
|
for i, part := range idParts {
|
|
if part == "crew" || part == "polecat" || part == "polecats" {
|
|
if i >= 1 && i < len(idParts)-1 {
|
|
rig := idParts[i-1]
|
|
name := strings.Join(idParts[i+1:], "-")
|
|
return rig + "/" + part + "/" + name
|
|
}
|
|
}
|
|
if part == "witness" || part == "refinery" {
|
|
if i >= 1 {
|
|
return idParts[i-1] + "/" + part
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle gt- prefixed IDs (legacy format)
|
|
if !strings.HasPrefix(id, "gt-") {
|
|
return "" // Not a valid agent bead ID
|
|
}'
|
|
|
|
# Fix crew/polecat home paths: remove incorrect /rig suffix
|
|
substituteInPlace internal/cmd/role.go \
|
|
--replace-fail \
|
|
'return filepath.Join(townRoot, rig, "polecats", polecat, "rig")' \
|
|
'return filepath.Join(townRoot, rig, "polecats", polecat)' \
|
|
--replace-fail \
|
|
'return filepath.Join(townRoot, rig, "crew", polecat, "rig")' \
|
|
'return filepath.Join(townRoot, rig, "crew", polecat)'
|
|
|
|
# Fix town root detection: don't map to Mayor (causes spurious mismatch warnings)
|
|
substituteInPlace internal/cmd/prime.go \
|
|
--replace-fail \
|
|
'if relPath == "." || relPath == "" {
|
|
ctx.Role = RoleMayor
|
|
return ctx
|
|
}
|
|
if len(parts) >= 1 && parts[0] == "mayor" {' \
|
|
'if relPath == "." || relPath == "" {
|
|
return ctx // RoleUnknown - town root is shared space
|
|
}
|
|
|
|
// Check for mayor role: mayor/ or mayor/rig/
|
|
if len(parts) >= 1 && parts[0] == "mayor" {'
|
|
|
|
# Fix copyDir to handle symlinks (broken symlinks cause "no such file" errors)
|
|
# See: https://github.com/steveyegge/gastown/issues/TBD
|
|
substituteInPlace internal/git/git.go \
|
|
--replace-fail \
|
|
'if entry.IsDir() {' \
|
|
'// Handle symlinks (recreate them, do not follow)
|
|
if entry.Type()&os.ModeSymlink != 0 {
|
|
linkTarget, err := os.Readlink(srcPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.Symlink(linkTarget, destPath); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
|
|
if entry.IsDir() {'
|
|
|
|
# Statusline optimization: skip detached sessions and cache results
|
|
# Reduces Dolt CPU from ~70% to ~20% by avoiding beads queries for sessions nobody is watching
|
|
# Cache functions already exist in upstream, we just add the early-return + cache writes
|
|
# See: https://github.com/steveyegge/gastown/issues/TBD
|
|
substituteInPlace internal/cmd/statusline.go \
|
|
--replace-fail \
|
|
'func runStatusLine(cmd *cobra.Command, args []string) error {
|
|
t := tmux.NewTmux()
|
|
|
|
// Get session environment' \
|
|
'func runStatusLine(cmd *cobra.Command, args []string) error {
|
|
t := tmux.NewTmux()
|
|
|
|
// Optimization: skip expensive beads queries for detached sessions
|
|
if statusLineSession != "" {
|
|
if !t.IsSessionAttached(statusLineSession) {
|
|
fmt.Print("○ |")
|
|
return nil
|
|
}
|
|
// Check cache for attached sessions too
|
|
if cached := getStatusLineCache(statusLineSession); cached != "" {
|
|
fmt.Print(cached)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Get session environment' \
|
|
--replace-fail \
|
|
'// Output
|
|
if len(parts) > 0 {
|
|
fmt.Print(strings.Join(parts, " | ") + " |")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// runMayorStatusLine' \
|
|
'// Output
|
|
if len(parts) > 0 {
|
|
output := strings.Join(parts, " | ") + " |"
|
|
if statusLineSession != "" {
|
|
setStatusLineCache(statusLineSession, output)
|
|
}
|
|
fmt.Print(output)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// runMayorStatusLine' \
|
|
--replace-fail \
|
|
'fmt.Print(strings.Join(parts, " | ") + " |")
|
|
return nil
|
|
}
|
|
|
|
// runDeaconStatusLine outputs status for the deacon session.' \
|
|
'output := strings.Join(parts, " | ") + " |"
|
|
if statusLineSession != "" {
|
|
setStatusLineCache(statusLineSession, output)
|
|
}
|
|
fmt.Print(output)
|
|
return nil
|
|
}
|
|
|
|
// runDeaconStatusLine outputs status for the deacon session.' \
|
|
--replace-fail \
|
|
'fmt.Print(strings.Join(parts, " | ") + " |")
|
|
return nil
|
|
}
|
|
|
|
// runWitnessStatusLine outputs status for a witness session.
|
|
// Shows: crew count, hook or mail preview' \
|
|
'output := strings.Join(parts, " | ") + " |"
|
|
if statusLineSession != "" {
|
|
setStatusLineCache(statusLineSession, output)
|
|
}
|
|
fmt.Print(output)
|
|
return nil
|
|
}
|
|
|
|
// runWitnessStatusLine outputs status for a witness session.
|
|
// Shows: crew count, hook or mail preview' \
|
|
--replace-fail \
|
|
'fmt.Print(strings.Join(parts, " | ") + " |")
|
|
return nil
|
|
}
|
|
|
|
// runRefineryStatusLine outputs status for a refinery session.' \
|
|
'output := strings.Join(parts, " | ") + " |"
|
|
if statusLineSession != "" {
|
|
setStatusLineCache(statusLineSession, output)
|
|
}
|
|
fmt.Print(output)
|
|
return nil
|
|
}
|
|
|
|
// runRefineryStatusLine outputs status for a refinery session.' \
|
|
--replace-fail \
|
|
'fmt.Print(strings.Join(parts, " | ") + " |")
|
|
return nil
|
|
}
|
|
|
|
// isSessionWorking detects' \
|
|
'output := strings.Join(parts, " | ") + " |"
|
|
if statusLineSession != "" {
|
|
setStatusLineCache(statusLineSession, output)
|
|
}
|
|
fmt.Print(output)
|
|
return nil
|
|
}
|
|
|
|
// isSessionWorking detects'
|
|
'';
|
|
|
|
meta = with lib; {
|
|
description = "Gas Town - multi-agent workspace manager by Steve Yegge";
|
|
homepage = "https://github.com/steveyegge/gastown";
|
|
license = licenses.mit;
|
|
mainProgram = "gt";
|
|
};
|
|
};
|
|
|
|
# Perles - TUI for beads issue tracking (no upstream flake.nix yet)
|
|
# Source is tracked via flake input for renovate updates
|
|
perlesRev = builtins.substring 0 8 (globalInputs.perles.rev or "unknown");
|
|
perlesPackage = pkgs.buildGoModule {
|
|
pname = "perles";
|
|
version = "unstable-${perlesRev}";
|
|
src = globalInputs.perles;
|
|
vendorHash = "sha256-JHERJDzbiqgjWXwRhXVjgDEiDQ3AUXRIONotfPF21B0=";
|
|
doCheck = false;
|
|
|
|
ldflags = [
|
|
"-X main.version=${perlesRev}"
|
|
];
|
|
|
|
meta = with lib; {
|
|
description = "Perles - Terminal UI for beads issue tracking";
|
|
homepage = "https://github.com/zjrosen/perles";
|
|
license = licenses.mit;
|
|
mainProgram = "perles";
|
|
};
|
|
};
|
|
|
|
# Fetch the claude-plugins repository (for humanlayer commands/agents)
|
|
# Update the rev to get newer versions of the commands
|
|
claudePluginsRepo = builtins.fetchGit {
|
|
url = "https://github.com/jeffh/claude-plugins.git";
|
|
# To update: change this to the latest commit hash
|
|
# You can find the latest commit at: https://github.com/jeffh/claude-plugins/commits/main
|
|
rev = "5e3e4d937162185b6d78c62022cbfd1c8ad42c4c";
|
|
ref = "main";
|
|
};
|
|
|
|
in
|
|
{
|
|
options.home.roles.development = {
|
|
enable = mkEnableOption "Enable development tools and utilities";
|
|
|
|
allowArbitraryClaudeCodeModelSelection = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Whether to preserve model specifications in Claude Code humanlayer commands and agents.
|
|
|
|
When false (default), the model: line is stripped from frontmatter, allowing Claude Code
|
|
to use its default model selection.
|
|
|
|
When true, the model: specifications from the source files are preserved, allowing
|
|
commands to specify opus/sonnet/haiku explicitly.
|
|
'';
|
|
};
|
|
};
|
|
|
|
config = mkIf cfg.enable {
|
|
home.packages = [
|
|
beadsPackage
|
|
gastownPackage
|
|
perlesPackage
|
|
pkgs.unstable.claude-code
|
|
pkgs.unstable.claude-code-router
|
|
pkgs.unstable.codex
|
|
pkgs.dolt
|
|
pkgs.sqlite
|
|
|
|
# Custom packages
|
|
pkgs.custom.tea-rbw
|
|
];
|
|
|
|
# Install Claude Code humanlayer command and agent plugins
|
|
home.activation.claudeCodeCommands = lib.hm.dag.entryAfter ["writeBoundary"] ''
|
|
# Clean up old plugin-installed commands and agents to avoid duplicates
|
|
rm -f ~/.claude/commands/humanlayer:* 2>/dev/null || true
|
|
rm -f ~/.claude/agents/humanlayer:* 2>/dev/null || true
|
|
|
|
# Create directories if they don't exist
|
|
mkdir -p ~/.claude/commands
|
|
mkdir -p ~/.claude/agents
|
|
|
|
# Copy all humanlayer command files and remove model specifications
|
|
for file in ${claudePluginsRepo}/humanlayer/commands/*.md; do
|
|
if [ -f "$file" ]; then
|
|
filename=$(basename "$file" .md)
|
|
dest="$HOME/.claude/commands/humanlayer:''${filename}.md"
|
|
rm -f "$dest" 2>/dev/null || true
|
|
|
|
# Copy file and conditionally remove the "model:" line from frontmatter
|
|
${if cfg.allowArbitraryClaudeCodeModelSelection
|
|
then "cp \"$file\" \"$dest\""
|
|
else "${pkgs.gnused}/bin/sed '/^model:/d' \"$file\" > \"$dest\""
|
|
}
|
|
chmod u+w "$dest" 2>/dev/null || true
|
|
fi
|
|
done
|
|
|
|
# Copy all humanlayer agent files and remove model specifications
|
|
for file in ${claudePluginsRepo}/humanlayer/agents/*.md; do
|
|
if [ -f "$file" ]; then
|
|
filename=$(basename "$file" .md)
|
|
dest="$HOME/.claude/agents/humanlayer:''${filename}.md"
|
|
rm -f "$dest" 2>/dev/null || true
|
|
|
|
# Copy file and conditionally remove the "model:" line from frontmatter
|
|
${if cfg.allowArbitraryClaudeCodeModelSelection
|
|
then "cp \"$file\" \"$dest\""
|
|
else "${pkgs.gnused}/bin/sed '/^model:/d' \"$file\" > \"$dest\""
|
|
}
|
|
chmod u+w "$dest" 2>/dev/null || true
|
|
fi
|
|
done
|
|
|
|
# Copy local commands from this repo (with retry for race conditions with running Claude)
|
|
for file in ${./commands}/*.md; do
|
|
if [ -f "$file" ]; then
|
|
filename=$(basename "$file" .md)
|
|
dest="$HOME/.claude/commands/''${filename}.md"
|
|
# Remove existing file first, then copy with retry on failure
|
|
rm -f "$dest" 2>/dev/null || true
|
|
if ! cp "$file" "$dest" 2>/dev/null; then
|
|
sleep 0.5
|
|
cp "$file" "$dest" || echo "Warning: Failed to copy $filename.md to commands"
|
|
fi
|
|
chmod u+w "$dest" 2>/dev/null || true
|
|
fi
|
|
done
|
|
|
|
# Copy local skills (reference materials) to skills subdirectory
|
|
mkdir -p ~/.claude/commands/skills
|
|
for file in ${./skills}/*.md; do
|
|
if [ -f "$file" ]; then
|
|
filename=$(basename "$file" .md)
|
|
dest="$HOME/.claude/commands/skills/''${filename}.md"
|
|
rm -f "$dest" 2>/dev/null || true
|
|
if ! cp "$file" "$dest" 2>/dev/null; then
|
|
sleep 0.5
|
|
cp "$file" "$dest" || echo "Warning: Failed to copy $filename.md to skills"
|
|
fi
|
|
chmod u+w "$dest" 2>/dev/null || true
|
|
fi
|
|
done
|
|
|
|
# Copy micro-skills (compact reusable knowledge referenced by formulas)
|
|
for file in ${./skills/micro}/*.md; do
|
|
if [ -f "$file" ]; then
|
|
dest="$HOME/.claude/commands/skills/$(basename "$file")"
|
|
rm -f "$dest" 2>/dev/null || true
|
|
cp "$file" "$dest"
|
|
chmod u+w "$dest" 2>/dev/null || true
|
|
fi
|
|
done
|
|
|
|
# Install beads formulas to user-level formula directory
|
|
mkdir -p ~/.beads/formulas
|
|
for file in ${./formulas}/*.formula.toml; do
|
|
if [ -f "$file" ]; then
|
|
dest="$HOME/.beads/formulas/$(basename "$file")"
|
|
rm -f "$dest" 2>/dev/null || true
|
|
cp "$file" "$dest"
|
|
chmod u+w "$dest" 2>/dev/null || true
|
|
fi
|
|
done
|
|
|
|
$DRY_RUN_CMD echo "Claude Code plugins installed: humanlayer commands/agents + local commands + local skills + formulas"
|
|
'';
|
|
|
|
# Set up beads Claude Code integration (hooks for SessionStart/PreCompact)
|
|
# This uses the CLI + hooks approach which is recommended over MCP for Claude Code
|
|
home.activation.claudeCodeBeadsSetup = lib.hm.dag.entryAfter ["writeBoundary" "claudeCodeCommands"] ''
|
|
# Run bd setup claude to install hooks into ~/.claude/settings.json
|
|
# This is idempotent - safe to run multiple times
|
|
${beadsPackage}/bin/bd setup claude 2>/dev/null || true
|
|
|
|
$DRY_RUN_CMD echo "Claude Code beads integration configured (hooks installed)"
|
|
'';
|
|
|
|
# Beads timer gate checker (Linux only - uses systemd)
|
|
# Runs every 5 minutes to auto-resolve expired timer gates across all beads projects
|
|
# This enables self-scheduling molecules (watchers, patrols, etc.)
|
|
systemd.user.services.beads-gate-check = lib.mkIf pkgs.stdenv.isLinux {
|
|
Unit = {
|
|
Description = "Check and resolve expired beads timer gates";
|
|
};
|
|
Service = {
|
|
Type = "oneshot";
|
|
# Check gates in all workspaces that have running daemons
|
|
ExecStart = pkgs.writeShellScript "beads-gate-check-all" ''
|
|
# Get list of workspaces from daemon registry
|
|
workspaces=$(${beadsPackage}/bin/bd daemon list --json 2>/dev/null | ${pkgs.jq}/bin/jq -r '.[].workspace // empty' 2>/dev/null)
|
|
|
|
if [ -z "$workspaces" ]; then
|
|
exit 0 # No beads workspaces, nothing to do
|
|
fi
|
|
|
|
for ws in $workspaces; do
|
|
if [ -d "$ws" ]; then
|
|
cd "$ws" && ${beadsPackage}/bin/bd gate check --type=timer --quiet 2>/dev/null || true
|
|
fi
|
|
done
|
|
'';
|
|
};
|
|
};
|
|
|
|
systemd.user.timers.beads-gate-check = lib.mkIf pkgs.stdenv.isLinux {
|
|
Unit = {
|
|
Description = "Periodic beads timer gate check";
|
|
};
|
|
Timer = {
|
|
OnBootSec = "5min";
|
|
OnUnitActiveSec = "5min";
|
|
};
|
|
Install = {
|
|
WantedBy = [ "timers.target" ];
|
|
};
|
|
};
|
|
|
|
# Note: modules must be imported at top-level home config
|
|
};
|
|
}
|