Linux laptop power policy for vibecoding

I start a coding agent on a long task, close the lid, and put my Linux laptop in my backpack.

By default, systemd-logind suspends the machine when the lid closes. The agent stops mid-task, its network connections time out, and when I open the lid again the sessions have stopped. Or I disable suspend and risk running completely out of battery.

What I want is a laptop that ignores the lid and keeps working until the battery is nearly empty, and then hibernates instead of dying, but doesn’t hibernate when the lid is open. Then I can close the lid to indicate I’m absent and willing to hibernate, and I can code from my backpack.

tl;dr

  • Set logind’s HandleLidSwitch, HandleLidSwitchExternalPower and HandleLidSwitchDocked to ignore, and closing the lid no longer suspends anything.
  • A systemd service polls /sys/class/power_supply and the ACPI lid state every 15 seconds.
  • At 5% battery with the lid closed and no charger, it runs systemctl hibernate.
  • At 5% battery with the lid open, it switches power-profiles-daemon to power-saver, the same switch as GNOME’s Power Saver toggle.
  • Hibernation goes to a swapfile on a LUKS root, which takes boot.resumeDevice and a resume_offset kernel parameter computed with filefrag.
  • A stale resume_offset loses the hibernated session, not the filesystem, so the daemon re-checks the swapfile’s first block before hibernating and refuses if it moved.

logind lets go of the lid

A daemon that decides what the lid means cannot share the job with logind, so logind is told to stay out of it:

services.logind.settings.Login = {
  HandleLidSwitch = lib.mkDefault "ignore";
  HandleLidSwitchExternalPower = lib.mkDefault "ignore";
  HandleLidSwitchDocked = lib.mkDefault "ignore";
};

All three are needed. HandleLidSwitch alone only covers running on battery; on a charger or a dock, logind reads the other two.

Four cases

The policy is a shell loop. Each round it reads the lid state, the lowest capacity across all BAT* supplies, and whether any Mains or USB supply is online. Stripped of logging, the dry-run branch, and the offset guard, the decision is:

if [ -n "$capacity" ] && [ "$capacity" -le "$threshold" ]; then
  if [ "$lid" = "closed" ] && { [ "$hibernate_on_ac" = "1" ] || [ "$ac_online" -eq 0 ]; }; then
    systemctl hibernate && sleep "$post_hibernate_delay"
  elif [ "$lid" != "closed" ]; then
    set_power_saver
  fi
fi

Above the threshold nothing happens, lid open or closed. That is the point: the agent keeps going.

At the threshold with the lid closed and a charger attached, nothing happens either, since the battery is on its way up. hibernateOnAC overrides that for chargers too weak to keep up.

At the threshold with the lid open, I am at the keyboard and can decide for myself. The daemon only switches to power-saver to buy some time.

The script reads every path from environment variables, so it runs against a fake sysfs. With BAT0/capacity at 4 and the charger offline:

$ POWER_POLICY_SYSFS=./sys POWER_POLICY_LID_FILE=./lid \
  POWER_POLICY_DRY_RUN=1 POWER_POLICY_ONESHOT=1 bash power-policy.sh
power-policy: starting (threshold=5%, poll=15s, profile=power-saver)
power-policy: battery 4%, lid closed, AC 0; would hibernate

And with the lid file changed to open:

power-policy: starting (threshold=5%, poll=15s, profile=power-saver)
power-policy: would set power profile to power-saver

Waking up at 4%

After resume the battery is still at 4%. Without a pause the loop would see the same numbers and hibernate again before I find the charger.

So after a successful systemctl hibernate, the loop sleeps for postHibernateDelay, five minutes by default. A separate oneshot unit, wanted by hibernate.target, switches to power-saver as soon as the machine is back:

systemd.services.power-policy-resume = {
  wantedBy = [ "hibernate.target" ];
  after = [ "systemd-hibernate.service" ];
  serviceConfig = {
    Type = "oneshot";
    ExecStart = "${pkgs.power-profiles-daemon}/bin/powerprofilesctl set ${cfg.powerSaverProfile}";
  };
};

If hibernation fails, the loop falls back to power-saver and keeps polling.

Hibernating to an encrypted swapfile

My swap is a file on the encrypted root, not a partition. The kernel resumes from a block device and needs to know where in it the swapfile starts. The module exposes both:

boot.resumeDevice = lib.mkIf (cfg.resumeDevice != null) cfg.resumeDevice;
boot.kernelParams = lib.optional (cfg.resumeOffset != null) "resume_offset=${toString cfg.resumeOffset}";

The device is the unlocked LUKS mapping, /dev/mapper/cryptroot. The offset is the physical offset of the swapfile’s first extent:

filefrag -v /var/lib/swapfile | awk '$1 == "0:" {print $4}'

resume_offset is a read pointer, not a write target. Hibernation writes the image through the active swap area. At boot the kernel reads the offset to find that image. If the swapfile is recreated at a different offset, the next boot reads the wrong block, fails the swap signature and image checksum, and boots normally. The hibernated session is gone, but no other file is overwritten.

That is still a bad way to lose a session, so the daemon does not trust the number. It compares the swapfile’s current first block with the configured offset once at startup and again immediately before hibernating. If they differ, it logs the mismatch and switches to power-saver instead of hibernating:

power-policy: refusing to hibernate: swapfile offset is 3606528, configured resumeOffset is 3606529

The laptop sets the swapfile and leaves the guard on:

swapfile = "/var/lib/swapfile";
verifyResumeOffset = true;

The check needs filefrag from e2fsprogs. If it cannot read the file, the daemon refuses rather than guess. A manual systemctl hibernate bypasses the daemon and the guard, so update the offset and rebuild before doing that.

The first version of the guard piped filefrag into awk ... exit, which got SIGPIPE on a 16G swapfile. With set -euo pipefail that killed the daemon every time it checked the offset. The version below reads the whole extent list instead.

The whole module

The examples are Nix because that is how I manage this laptop. The policy itself is plain Linux: systemd-logind, ACPI, sysfs, and power-profiles-daemon. It is a dendritic flake-parts module that exports nixosModules.power-policy. The shell script lives inside it as a writeShellScript, and the options become environment variables:

# modules/power-policy.nix
{ ... }:
{
  flake.nixosModules.power-policy =
    { config, lib, pkgs, ... }:
    let
      cfg = config.services.power-policy;

      powerPolicy = pkgs.writeShellScript "power-policy" ''
        set -euo pipefail

        threshold="''${POWER_POLICY_THRESHOLD:-5}"
        poll="''${POWER_POLICY_POLL_INTERVAL:-15}"
        profile="''${POWER_POLICY_POWER_SAVER_PROFILE:-power-saver}"
        lid_file="''${POWER_POLICY_LID_FILE:-}"
        sysfs="''${POWER_POLICY_SYSFS:-/sys/class/power_supply}"
        dry_run="''${POWER_POLICY_DRY_RUN:-0}"
        hibernate_on_ac="''${POWER_POLICY_HIBERNATE_ON_AC:-0}"
        post_hibernate_delay="''${POWER_POLICY_POST_HIBERNATE_DELAY:-300}"
        swapfile="''${POWER_POLICY_SWAPFILE:-}"
        resume_offset="''${POWER_POLICY_RESUME_OFFSET:-}"
        verify_resume_offset="''${POWER_POLICY_VERIFY_RESUME_OFFSET:-1}"

        log() {
          printf 'power-policy: %s\n' "$*"
        }

        set_power_saver() {
          if [ "$dry_run" = "1" ]; then
            log "would set power profile to $profile"
            return 0
          fi
          if [ "$(powerprofilesctl get 2>/dev/null || true)" = "$profile" ]; then
            return 0
          fi
          if powerprofilesctl set "$profile"; then
            log "set power profile to $profile"
          else
            log "could not set power profile to $profile"
          fi
        }

        read_capacity() {
          local b="$1" now full
          if [ -r "$b/capacity" ]; then
            cat "$b/capacity"
            return 0
          fi
          if [ -r "$b/energy_now" ] && [ -r "$b/energy_full" ]; then
            now="$(cat "$b/energy_now")"
            full="$(cat "$b/energy_full")"
          elif [ -r "$b/charge_now" ] && [ -r "$b/charge_full" ]; then
            now="$(cat "$b/charge_now")"
            full="$(cat "$b/charge_full")"
          else
            return 1
          fi
          if [ "$full" -gt 0 ]; then
            echo $(( now * 100 / full ))
          fi
        }

        swapfile_offset() {
          filefrag -v "$swapfile" 2>/dev/null | awk '$1 == "0:" { sub(/\..*/, "", $4); print $4 }' || true
        }

        if [ "$verify_resume_offset" = "1" ] && [ -n "$swapfile" ] && [ -n "$resume_offset" ]; then
          actual_offset="$(swapfile_offset)"
          if [ "$actual_offset" != "$resume_offset" ]; then
            log "warning: swapfile offset is ''${actual_offset:-unknown}, configured resumeOffset is $resume_offset; hibernation will be refused"
          fi
        fi

        log "starting (threshold=$threshold%, poll=''${poll}s, profile=$profile)"
        while :; do
          if [ -z "$lid_file" ]; then
            lid_file="$(find /proc/acpi/button/lid -name state -type f 2>/dev/null | head -n 1 || true)"
          fi
          lid="unknown"
          if [ -n "$lid_file" ] && [ -r "$lid_file" ]; then
            lid="$(awk '{print $2}' "$lid_file")"
          fi

          capacity=""
          for b in "$sysfs"/BAT*; do
            [ -d "$b" ] || continue
            c="$(read_capacity "$b" 2>/dev/null || true)"
            case "$c" in
              "" | *[!0-9]*) continue ;;
            esac
            if [ -z "$capacity" ] || [ "$c" -lt "$capacity" ]; then
              capacity="$c"
            fi
          done

          ac_online=0
          for a in "$sysfs"/*; do
            [ -d "$a" ] || continue
            supply_type="$(cat "$a/type" 2>/dev/null || true)"
            case "$supply_type" in
              Mains | USB)
                if [ "$(cat "$a/online" 2>/dev/null || echo 0)" = "1" ]; then
                  ac_online=1
                fi
                ;;
            esac
          done

          if [ -n "$capacity" ] && [ "$capacity" -le "$threshold" ]; then
            if [ "$lid" = "closed" ] && { [ "$hibernate_on_ac" = "1" ] || [ "$ac_online" -eq 0 ]; }; then
              can_hibernate=1
              if [ "$verify_resume_offset" = "1" ] && [ -n "$swapfile" ] && [ -n "$resume_offset" ]; then
                actual_offset="$(swapfile_offset)"
                if [ "$actual_offset" != "$resume_offset" ]; then
                  log "refusing to hibernate: swapfile offset is ''${actual_offset:-unknown}, configured resumeOffset is $resume_offset"
                  can_hibernate=0
                fi
              fi
              if [ "$can_hibernate" = "1" ]; then
                if [ "$dry_run" = "1" ]; then
                  log "battery ''${capacity}%, lid closed, AC ''${ac_online}; would hibernate"
                else
                  log "battery ''${capacity}%, lid closed, AC ''${ac_online}; hibernating"
                  if systemctl hibernate; then
                    sleep "$post_hibernate_delay"
                  else
                    log "hibernate failed; falling back to power saver"
                    set_power_saver
                    sleep "$poll"
                  fi
                fi
              else
                set_power_saver
              fi
            elif [ "$lid" != "closed" ]; then
              set_power_saver
            fi
          fi

          if [ "''${POWER_POLICY_ONESHOT:-0}" = "1" ]; then
            break
          fi
          sleep "$poll"
        done
      '';
    in
    {
      options.services.power-policy = {
        enable = lib.mkEnableOption "lid/battery power policy";

        batteryThreshold = lib.mkOption {
          type = lib.types.ints.between 1 100;
          default = 5;
          description = "Battery percentage at or below which to hibernate when the lid is closed.";
        };

        pollInterval = lib.mkOption {
          type = lib.types.ints.positive;
          default = 15;
          description = "Seconds between battery and lid checks.";
        };

        powerSaverProfile = lib.mkOption {
          type = lib.types.str;
          default = "power-saver";
          description = "power-profiles-daemon profile to activate instead of hibernating.";
        };

        lidStateFile = lib.mkOption {
          type = lib.types.str;
          default = "";
          description = "Lid state file. Empty means auto-detect under /proc/acpi/button/lid.";
        };

        hibernateOnAC = lib.mkOption {
          type = lib.types.bool;
          default = false;
          description = "Hibernate at the threshold even while AC power is connected.";
        };

        postHibernateDelay = lib.mkOption {
          type = lib.types.ints.positive;
          default = 300;
          description = "Seconds to wait after resuming from hibernation before checking again.";
        };

        resumeDevice = lib.mkOption {
          type = lib.types.nullOr lib.types.str;
          default = null;
          description = "Block device containing the hibernation swapfile or partition (e.g. /dev/mapper/cryptroot).";
        };

        resumeOffset = lib.mkOption {
          type = lib.types.nullOr lib.types.int;
          default = null;
          description = "Physical offset of the swapfile's first block, as reported by filefrag's physical_offset column.";
        };

        swapfile = lib.mkOption {
          type = lib.types.nullOr lib.types.str;
          default = null;
          description = "Swapfile used for hibernation. When set, the daemon verifies its first physical block against resumeOffset before hibernating.";
        };

        verifyResumeOffset = lib.mkOption {
          type = lib.types.bool;
          default = true;
          description = "Refuse to hibernate if the swapfile's current first block no longer matches resumeOffset.";
        };
      };

      config = lib.mkIf cfg.enable {
        # The policy daemon owns lid behavior; logind must not act on its own.
        services.logind.settings.Login = {
          HandleLidSwitch = lib.mkDefault "ignore";
          HandleLidSwitchExternalPower = lib.mkDefault "ignore";
          HandleLidSwitchDocked = lib.mkDefault "ignore";
        };

        # powerprofilesctl is the same switch as GNOME's Power Saver toggle.
        services.power-profiles-daemon.enable = lib.mkDefault true;

        boot.resumeDevice = lib.mkIf (cfg.resumeDevice != null) cfg.resumeDevice;
        boot.kernelParams = lib.optional (cfg.resumeOffset != null) "resume_offset=${toString cfg.resumeOffset}";

        systemd.services.power-policy = {
          description = "Lid/battery power policy";
          wantedBy = [ "multi-user.target" ];
          wants = lib.optional config.services.power-profiles-daemon.enable "power-profiles-daemon.service";
          after = lib.optional config.services.power-profiles-daemon.enable "power-profiles-daemon.service";
          path = with pkgs; [
            coreutils
            e2fsprogs
            findutils
            gawk
            power-profiles-daemon
            systemd
          ];
          environment = {
            POWER_POLICY_THRESHOLD = toString cfg.batteryThreshold;
            POWER_POLICY_POLL_INTERVAL = toString cfg.pollInterval;
            POWER_POLICY_POWER_SAVER_PROFILE = cfg.powerSaverProfile;
            POWER_POLICY_LID_FILE = cfg.lidStateFile;
            POWER_POLICY_HIBERNATE_ON_AC = if cfg.hibernateOnAC then "1" else "0";
            POWER_POLICY_POST_HIBERNATE_DELAY = toString cfg.postHibernateDelay;
            POWER_POLICY_SWAPFILE = if cfg.swapfile != null then cfg.swapfile else "";
            POWER_POLICY_RESUME_OFFSET = if cfg.resumeOffset != null then toString cfg.resumeOffset else "";
            POWER_POLICY_VERIFY_RESUME_OFFSET = if cfg.verifyResumeOffset then "1" else "0";
          };
          serviceConfig = {
            Type = "simple";
            ExecStart = "${powerPolicy}";
            Restart = "always";
            RestartSec = 5;
          };
        };

        systemd.services.power-policy-resume = {
          description = "Activate power saver after resuming from hibernation";
          wantedBy = [ "hibernate.target" ];
          after = [ "systemd-hibernate.service" ];
          serviceConfig = {
            Type = "oneshot";
            ExecStart = "${pkgs.power-profiles-daemon}/bin/powerprofilesctl set ${cfg.powerSaverProfile}";
          };
        };
      };
    };
}

The laptop enables it like this:

# modules/nixos-configurations.nix
config.flake.nixosModules.power-policy
{
  services.power-policy = {
    enable = true;
    # /var/lib/swapfile lives on the encrypted root. Recompute the offset
    # if the swapfile is recreated:
    #   filefrag -v /var/lib/swapfile | awk '$1 == "0:" {print $4}'
    resumeDevice = "/dev/mapper/cryptroot";
    resumeOffset = 3606528;
    swapfile = "/var/lib/swapfile";
  };
}
Made by Simon Shine·Last updated 24 October 2026