Files
dotfiles/docs/superpowers/plans/2026-05-10-lid-hibernate.md
funman300 4e69e155d7 docs: implementation plan for lid-close hibernate
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:05:26 -07:00

13 KiB
Raw Permalink Blame History

Lid-Close Hibernate Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make lid close (battery or AC) and 30-min idle both reach hibernation via the kernel's suspend-then-hibernate mode, after first enabling hibernation on the system.

Architecture: Two layers. (1) A one-time, idempotent bootstrap script (scripts/enable-hibernation.sh) that creates a 16 GiB btrfs swap file, adds the resume mkinitcpio hook, appends resume= + resume_offset= to the kernel cmdline, and regenerates the UKI. Reboot required after. (2) Tracked dotfiles (logind drop-in, sleep drop-in, niri swayidle line) deployed by install.sh as usual — no reboot needed for these.

Tech Stack: bash, systemd-logind, systemd-sleep, mkinitcpio, btrfs, ukify (via mkinitcpio -P), niri.

Spec: docs/superpowers/specs/2026-05-10-lid-hibernate-design.md

Verification model: Bash + system commands. Subagents can bash -n shell scripts and read INI files but cannot run sudo or reboot. Each task ends with a syntax check + a list of one-line user-side post-deploy verifications the controller will run.


File Structure

File Role
scripts/enable-hibernation.sh One-time idempotent bootstrap. Creates swap, edits /etc/mkinitcpio.conf, /etc/kernel/cmdline, regenerates UKI.
logind/lid.conf Tracked drop-in for /etc/systemd/logind.conf.d/ — sets all three lid handlers to suspend-then-hibernate (or ignore for docked).
sleep/hibernate-delay.conf Tracked drop-in for /etc/systemd/sleep.conf.d/ — sets HibernateDelaySec=30min.
install.sh Modified: adds two sudo install -Dm644 lines to deploy the drop-ins.
niri/config.kdl Modified: line 39 swayidle action systemctl suspendsystemctl suspend-then-hibernate.
README.md Modified: appended subsection "One-time hibernation enablement" with the run-once instruction.

Task 1: Bootstrap script

After this task, sudo bash scripts/enable-hibernation.sh enables hibernation end-to-end. Re-runs are no-ops. The script does NOT auto-reboot — it prints instructions.

Files:

  • Create: scripts/enable-hibernation.sh

  • Step 1: Create the script

Write scripts/enable-hibernation.sh with the following exact content:

#!/bin/bash
# enable-hibernation.sh — one-time setup for laptop hibernation on btrfs.
# Idempotent: re-runs are no-ops once everything is in place. Run with sudo.
# After successful run, reboot before testing `systemctl hibernate`.

set -euo pipefail

if [ "$EUID" -ne 0 ]; then
    echo "Run as root: sudo bash $0" >&2
    exit 1
fi

SWAPFILE=/swapfile
SWAPSIZE=16g
ROOT_PARTUUID=$(findmnt -no PARTUUID /)

echo "==> Step 1/6: ensure swap file exists at $SWAPFILE (size $SWAPSIZE)"
if [ ! -f "$SWAPFILE" ]; then
    btrfs filesystem mkswapfile --size "$SWAPSIZE" "$SWAPFILE"
    echo "    created $SWAPFILE"
else
    actual_bytes=$(stat -c %s "$SWAPFILE")
    min_bytes=$((14 * 1024 * 1024 * 1024))
    if [ "$actual_bytes" -lt "$min_bytes" ]; then
        echo "ERROR: $SWAPFILE exists but is smaller than 14 GiB (got $actual_bytes bytes)." >&2
        echo "       Remove it manually and re-run." >&2
        exit 1
    fi
    echo "    already exists ($((actual_bytes / 1024 / 1024 / 1024)) GiB) — skipping creation"
fi

echo "==> Step 2/6: activate swap and persist in fstab"
if ! swapon --show | grep -q "^$SWAPFILE "; then
    swapon "$SWAPFILE"
    echo "    swapon $SWAPFILE"
else
    echo "    already active — skipping swapon"
fi
if ! grep -qE "^$SWAPFILE\s" /etc/fstab; then
    printf '%s\tnone\tswap\tdefaults\t0 0\n' "$SWAPFILE" >> /etc/fstab
    echo "    appended to /etc/fstab"
else
    echo "    /etc/fstab already references $SWAPFILE — skipping"
fi

echo "==> Step 3/6: compute resume params"
RESUME_OFFSET=$(btrfs inspect-internal map-swapfile -r "$SWAPFILE")
echo "    resume=PARTUUID=$ROOT_PARTUUID"
echo "    resume_offset=$RESUME_OFFSET"

echo "==> Step 4/6: ensure 'resume' hook in /etc/mkinitcpio.conf"
if grep -qE "^HOOKS=\(.*\bresume\b.*\)" /etc/mkinitcpio.conf; then
    echo "    resume hook already present — skipping"
else
    sed -i -E 's/(^HOOKS=\(.*\bblock\b)/\1 resume/' /etc/mkinitcpio.conf
    if ! grep -qE "^HOOKS=\(.*\bresume\b.*\)" /etc/mkinitcpio.conf; then
        echo "ERROR: failed to insert 'resume' hook. Check /etc/mkinitcpio.conf manually." >&2
        exit 1
    fi
    echo "    inserted 'resume' after 'block'"
fi

echo "==> Step 5/6: append resume params to /etc/kernel/cmdline"
if grep -q "resume=" /etc/kernel/cmdline; then
    echo "    cmdline already contains resume= — skipping"
else
    sed -i "1 s|\$| resume=PARTUUID=$ROOT_PARTUUID resume_offset=$RESUME_OFFSET|" /etc/kernel/cmdline
    echo "    appended to /etc/kernel/cmdline"
fi

echo "==> Step 6/6: regenerate UKI"
mkinitcpio -P

echo
echo "Done. Reboot to activate hibernation:"
echo "    sudo reboot"
echo
echo "After reboot, test with: systemctl hibernate"
  • Step 2: Make it executable
chmod +x scripts/enable-hibernation.sh
ls -la scripts/enable-hibernation.sh

Expected: file mode shows -rwxr-xr-x (or similar with x bits set).

  • Step 3: Syntax check
bash -n scripts/enable-hibernation.sh && echo "syntax ok"

Expected: syntax ok and exit 0.

  • Step 4: Dry-run verify the idempotency guards (without sudo)

The script bails on the EUID check first if not root, so we can safely "run" it as a non-root user to confirm the bail-out:

bash scripts/enable-hibernation.sh 2>&1 | head -3

Expected output:

Run as root: sudo bash scripts/enable-hibernation.sh

Exit code 1.

Also visually inspect the script — confirm each of the six steps has a guard (if [ ! -f ], if ! grep, etc.) so a re-run is a no-op.

  • Step 5: Commit
git add scripts/enable-hibernation.sh
git commit -m "scripts: add enable-hibernation.sh (one-time bootstrap)"

Task 2: Tracked drop-in configs + install.sh deploy

After this task, the repo carries the two drop-in files and install.sh deploys them. Re-running install.sh is safe and overwrites the live drop-ins to match the tracked versions (matches the existing greetd pattern).

Files:

  • Create: logind/lid.conf

  • Create: sleep/hibernate-delay.conf

  • Modify: install.sh (add two lines after the existing ==> Deploying greetd config block)

  • Step 1: Create logind/lid.conf

mkdir -p logind

Write logind/lid.conf with this exact content:

[Login]
HandleLidSwitch=suspend-then-hibernate
HandleLidSwitchExternalPower=suspend-then-hibernate
HandleLidSwitchDocked=ignore
  • Step 2: Create sleep/hibernate-delay.conf
mkdir -p sleep

Write sleep/hibernate-delay.conf with this exact content:

[Sleep]
HibernateDelaySec=30min
  • Step 3: Verify both INI files
cat logind/lid.conf
cat sleep/hibernate-delay.conf

Expected: each file's contents print verbatim as above.

  • Step 4: Add deploy lines to install.sh

Find the existing greetd deploy block in install.sh:

echo "==> Deploying greetd config"
sudo cp "$(pwd)/greetd/config.toml" /etc/greetd/config.toml
sudo cp "$(pwd)/greetd/regreet.toml" /etc/greetd/regreet.toml

Immediately after the second sudo cp line, insert:


echo "==> Deploying suspend/hibernate config"
sudo install -Dm644 "$(pwd)/logind/lid.conf" /etc/systemd/logind.conf.d/lid.conf
sudo install -Dm644 "$(pwd)/sleep/hibernate-delay.conf" /etc/systemd/sleep.conf.d/hibernate-delay.conf

install -Dm644 creates intermediate directories if they don't exist (the .conf.d paths may not exist on a fresh install) and sets file mode to 644 — the right tool for system drop-in files.

  • Step 5: Bash-syntax-check install.sh
bash -n install.sh && echo "syntax ok"

Expected: syntax ok and exit 0.

  • Step 6: Confirm the new lines appear in the right place
grep -A 4 "==> Deploying suspend" install.sh

Expected output:

echo "==> Deploying suspend/hibernate config"
sudo install -Dm644 "$(pwd)/logind/lid.conf" /etc/systemd/logind.conf.d/lid.conf
sudo install -Dm644 "$(pwd)/sleep/hibernate-delay.conf" /etc/systemd/sleep.conf.d/hibernate-delay.conf
  • Step 7: Commit
git add logind/lid.conf sleep/hibernate-delay.conf install.sh
git commit -m "logind+sleep: track lid hibernate config; deploy via install.sh"

Task 3: niri swayidle action

After this task, the 30-min idle path uses systemctl suspend-then-hibernate instead of systemctl suspend, matching the lid handler.

Files:

  • Modify: niri/config.kdl (line 39)

  • Step 1: Replace the swayidle action

In niri/config.kdl, find line 39:

spawn-at-startup "swayidle" "-w" "timeout" "300" "niri msg action power-off-monitors" "timeout" "600" "gtklock" "-d" "timeout" "1800" "systemctl suspend" "before-sleep" "gtklock" "-d"

Replace "systemctl suspend" with "systemctl suspend-then-hibernate". The full line becomes:

spawn-at-startup "swayidle" "-w" "timeout" "300" "niri msg action power-off-monitors" "timeout" "600" "gtklock" "-d" "timeout" "1800" "systemctl suspend-then-hibernate" "before-sleep" "gtklock" "-d"

Only one substring changes; nothing else on the line moves. The before-sleep "gtklock" "-d" clause stays — gtklock fires on suspend-then-hibernate's suspend phase too.

  • Step 2: Validate niri config
niri validate 2>&1 | tail -2

Expected: a final line INFO niri: config is valid. Any "ERROR" or non-zero exit means a syntax issue.

  • Step 3: Confirm exactly one substitution happened
grep -c "systemctl suspend-then-hibernate" niri/config.kdl
grep -c "\"systemctl suspend\"" niri/config.kdl

Expected:

  • First count: 1 (the new value is present)

  • Second count: 0 (the old exact-string "systemctl suspend" is gone)

  • Step 4: Commit

git add niri/config.kdl
git commit -m "niri: route swayidle 30-min timeout through suspend-then-hibernate"

Task 4: README — One-time hibernation enablement subsection

After this task, the README documents the one-time bootstrap step so a reader setting up the system fresh knows to run the script.

Files:

  • Modify: README.md (append a subsection)

  • Step 1: Append the new subsection

The README has exactly one top-level section, ## Setup (line 22). The new subsection nests under it as ### One-time hibernation enablement.

Append to the end of README.md (after the closing line of the existing Setup section):


### One-time hibernation enablement

Hibernation requires a persistent swap file, kernel resume parameters, and a regenerated UKI. Run once, then reboot:

```bash
sudo bash scripts/enable-hibernation.sh
```

After reboot, `systemctl hibernate` will work, and lid close / 30-min idle will suspend-then-hibernate per the tracked logind config.

(The outer fence above is four backticks because the block contains a triple-backtick code block — paste the inner content verbatim, no enclosing 4-backtick fence.)

  • Step 2: Verify the section was added
grep -A 6 "One-time hibernation enablement" README.md

Expected: the new subsection prints, including the sudo bash scripts/enable-hibernation.sh line.

  • Step 3: Commit
git add README.md
git commit -m "docs: document one-time hibernation enablement"

Final verification (controller, post-merge)

These can only be run on the live system, after merging and pushing all four task commits.

  • Bootstrap and reboot
sudo bash scripts/enable-hibernation.sh
# Output should show steps 16 with each guard either firing or skipping.
sudo reboot
  • After reboot, verify prerequisites
swapon --show                                 # /swapfile, ≥14 GiB
cat /proc/cmdline | grep -oE "resume=\S+ resume_offset=\S+"   # both present
grep "^HOOKS=" /etc/mkinitcpio.conf | grep resume              # hook in array
  • Re-deploy tracked configs
cd ~/Documents/dotfiles
bash install.sh
# Should print "==> Deploying suspend/hibernate config" and the two install lines.
ls -la /etc/systemd/logind.conf.d/lid.conf /etc/systemd/sleep.conf.d/hibernate-delay.conf

Expected: both files exist, mode 644, owned by root.

  • Reload logind so lid.conf takes effect
sudo systemctl restart systemd-logind   # closes session — log back in after

(Optional: a niri restart also picks up the new swayidle args. The change takes effect on next login regardless.)

  • Functional checks
  1. systemctl hibernate — laptop powers off completely; press power → resumes to gtklock in ~2030 s.
  2. Close lid on battery — laptop suspends instantly; reopen within 30 min → instant resume; reopen after 30+ min → ~30 s resume from hibernation.
  3. Close lid on AC — same as battery (no longer just locks).
  4. Leave laptop idle 30 min — journalctl -u systemd-suspend-then-hibernate.service shows the action firing.