diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index c770e09..a46fb8c 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -55,10 +55,16 @@ or (b) undock → `nomarchy-display-transition undock` re-enabling the panel. Both failed. `after_sleep_cmd` only does `hyprctl dispatch dpms on` and never re-enables a disabled internal. +**Progress 2026-07-15 (no-SSH path + recovery hardening):** undock/enable +always end with `dpms on`; hypridle `on-resume` / `after_sleep` run +`nomarchy-display-wake` (dpms + zero-output undock rescue + auto-dump); +`nomarchy-display-dump` + SUPER+SHIFT+D + TTY (`Ctrl+Alt+F3`) so capture +does not need SSH. V3 still needs a real docked idle repro. + **Pass:** docked on AC, idle past lock + DPMS; external KB/mouse restores the external; if the external is unplugged while black, eDP comes back without reboot; journal shows display-watch undock `result=ok` when the -cable leaves. Capture steps in HARDWARE-QUEUE. +cable leaves. Capture steps in HARDWARE-QUEUE (TTY dump preferred). ## NEXT diff --git a/agent/HARDWARE-QUEUE.md b/agent/HARDWARE-QUEUE.md index 8d2158a..d1ed0b3 100644 --- a/agent/HARDWARE-QUEUE.md +++ b/agent/HARDWARE-QUEUE.md @@ -57,25 +57,27 @@ Everything else below stays open; order is convenience, not a gate. was needed; a long Grok session died with the brick. **Important distinction:** on AC, hypridle is *not* supposed to suspend — only lock (5m) + `dpms off` (10m). Confirm next time with - `journalctl -b | rg -i 'Suspending|PM: suspend'` vs hypridle/hyprlock - lines so we know DPMS-stuck vs real S3. - **Repro / capture (next time, before reboot if possible — SSH/TTY):** - 1. Dock, confirm dock mode (eDP disabled in `hyprctl monitors all -j`). - 2. Leave idle past 10 minutes (or force: `loginctl lock-session` then - `hyprctl dispatch dpms off`). - 3. Try external KB key / mouse move — does external wake? - 4. Unplug external — does eDP return within a few seconds? - 5. Capture (SSH from another machine if the seat is blind): - ``` - journalctl --user -t nomarchy-display-watch -t nomarchy-display-transition \ - -t hypridle --since '-30 min' - journalctl -b --since '-30 min' | rg -i 'suspend|sleep|dpms|lid|hyprlock' - hyprctl monitors all -j; hyprctl keyword monitor 2>&1 | head - systemd-inhibit --list - ``` - **Pass:** input wakes the external while docked; undock while black - restores eDP without power cycle. Related: docking recovery round 8 - (awake undock) and BACKLOG #127. + dump file or journal for `Suspending|PM: suspend` vs hypridle lines + so we know DPMS-stuck vs real S3. + **No SSH required** (post-#127 tooling): Caps Lock alive ⇒ machine is + up. Prefer **local TTY** over SSH: + 1. `Ctrl+Alt+F3` → log in on the text console. + 2. `nomarchy-display-wake` — try recovery (dpms on + undock rescue). + 3. `nomarchy-display-dump` — writes `~/nomarchy-display-dump-*.txt` + (monitors, journals, DRM status). Open that file after the seat + is usable; no second machine needed. + 4. Optional while unlocked: `SUPER+SHIFT+D` runs the same dump. + Auto: undock enable-fail and zero-output wake also dump under `~/`. + **Repro (after `nomarchy-home` + relogin so hypridle/watcher pick up):** + 1. Dock, confirm dock mode (eDP disabled). + 2. Idle past 10 min **or** force: `loginctl lock-session` then + `hyprctl dispatch dpms off`. + 3. External KB/mouse — external should wake (`nomarchy-display-wake` + path). If not: TTY → dump → note. + 4. Unplug external while black — eDP should return (undock + `dpms on`). + **Pass:** input wakes external while docked; undock while black restores + eDP without reboot; dump files only appear on real failures. + Related: docking recovery round 8 (awake undock) and BACKLOG #127. - [ ] **#96 battery-limit row on threshold-less firmware (Acer M5-481T)** — on the Acer (live or installed session with the commit carrying this entry): open System (`SUPER+CTRL+I`). **Pass:** a "Battery limit" row diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 037595c..cd559b2 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -19,6 +19,15 @@ Template: --- +## 2026-07-15 — #127 no-SSH dump + docked idle wake harden +- **Task:** Tackle docked idle blackout without needing SSH mid-brick. +- **Did:** `nomarchy-display-dump` / `nomarchy-display-wake`; undock always + `dpms on`; hypridle resume/after_sleep → wake rescue; SUPER+SHIFT+D; + HARDWARE-QUEUE #127 rewritten for Ctrl+Alt+F3 TTY path. +- **Verified:** V0/V1 flake check. V3: docked idle on AMD box. +- **Pending:** real repro; keep #127 open until V3 pass. +- **Next suggestion:** Bernardo runs forced DPMS dock test + TTY dump if needed. + ## 2026-07-15 — #115 suspend-then-hibernate (1h, battery-only + menu) - **Task:** Human settled delay=1h, battery only, menu enable/disable. - **Did:** `nomarchy-suspend` + logind/sleep bridge from diff --git a/modules/home/display-tools.nix b/modules/home/display-tools.nix new file mode 100644 index 0000000..36b2644 --- /dev/null +++ b/modules/home/display-tools.nix @@ -0,0 +1,103 @@ +# Shared display recovery helpers (#127). Used by hyprland.nix (PATH + +# undock dump) and idle.nix (hypridle on-resume / after_sleep absolute paths). +# +# dump — evidence to ~/nomarchy-display-dump-*.txt without SSH +# wake — dpms on + zero-output undock rescue + dump if still dark +# Pass displayTransition so wake pins the same binary hyprland ships. +# Call once with only pkgs to get dump (transition not ready yet), then +# again with displayTransition for wake. +{ pkgs, displayTransition ? null }: + +let + dump = pkgs.writeShellScriptBin "nomarchy-display-dump" '' + set -u + reason="''${1:-manual}" + stamp=$(date +%Y%m%d-%H%M%S) + out="''${HOME:-/tmp}/nomarchy-display-dump-''${stamp}.txt" + { + echo "=== nomarchy-display-dump reason=$reason ===" + echo "date: $(date -Is 2>/dev/null || date)" + echo "user: $(id -un 2>/dev/null || echo ?)" + echo "HYPRLAND_INSTANCE_SIGNATURE=''${HYPRLAND_INSTANCE_SIGNATURE:-}" + echo + echo "=== hyprctl monitors (enabled) ===" + hyprctl monitors -j 2>&1 || true + echo + echo "=== hyprctl monitors all ===" + hyprctl monitors all -j 2>&1 || true + echo + echo "=== hyprctl workspaces ===" + hyprctl workspaces -j 2>&1 || true + echo + echo "=== hyprctl devices (keyboards) ===" + hyprctl devices -j 2>/dev/null | ${pkgs.jq}/bin/jq '.keyboards // .' 2>&1 || true + echo + echo "=== systemd-inhibit --list ===" + ${pkgs.systemd}/bin/systemd-inhibit --list 2>&1 || true + echo + echo "=== DRM connector status ===" + for s in /sys/class/drm/*/status; do + [ -r "$s" ] || continue + echo "$s: $(cat "$s" 2>/dev/null)" + done + echo + echo "=== journal user display/idle (last 100) ===" + journalctl --user -t nomarchy-display-watch -t nomarchy-display-transition \ + -t nomarchy-display-wake -t hypridle -t hyprlock -n 100 --no-pager 2>&1 || true + echo + echo "=== journal -b suspend/sleep/dpms (last 80 matching) ===" + journalctl -b --no-pager 2>/dev/null \ + | ${pkgs.gnugrep}/bin/grep -iE 'Suspending|PM: suspend|PM: hibernate|dpms|sleep\.target|systemd-sleep' \ + | ${pkgs.coreutils}/bin/tail -n 80 || true + } >"$out" 2>&1 + ${pkgs.util-linux}/bin/logger -t nomarchy-display-dump -- "reason=$reason wrote $out" + command -v notify-send >/dev/null 2>&1 \ + && notify-send "Display dump" "$out" 2>/dev/null || true + printf '%s\n' "$out" + ''; + + wake = + if displayTransition == null then null + else pkgs.writeShellScriptBin "nomarchy-display-wake" '' + set -u + LOGGER=${pkgs.util-linux}/bin/logger + log() { "$LOGGER" -t nomarchy-display-wake -- "$*"; } + TRANSITION=${displayTransition}/bin/nomarchy-display-transition + DUMP=${dump}/bin/nomarchy-display-dump + hyprctl dispatch dpms on >/dev/null 2>&1 || true + enabled=$(hyprctl monitors -j 2>/dev/null | ${pkgs.jq}/bin/jq 'length' 2>/dev/null || echo 0) + case "$enabled" in *[!0-9]*|"") enabled=0 ;; esac + if [ "$enabled" -eq 0 ]; then + log "zero-enabled-outputs: attempting internal enable" + internal=$(hyprctl monitors all -j 2>/dev/null \ + | ${pkgs.jq}/bin/jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)")) | .name' 2>/dev/null \ + | ${pkgs.coreutils}/bin/head -n1) + if [ -n "$internal" ]; then + if "$TRANSITION" undock "$internal" 2>/dev/null; then + log "rescued via undock internal=$internal" + elif "$TRANSITION" enable "$internal" 2>/dev/null; then + log "rescued via enable internal=$internal" + else + log "rescue failed internal=$internal" + fi + else + log "no internal output in monitors all" + fi + hyprctl dispatch dpms on >/dev/null 2>&1 || true + enabled=$(hyprctl monitors -j 2>/dev/null | ${pkgs.jq}/bin/jq 'length' 2>/dev/null || echo 0) + case "$enabled" in *[!0-9]*|"") enabled=0 ;; esac + if [ "$enabled" -eq 0 ]; then + log "still zero-enabled: dumping" + "$DUMP" auto-zero-after-wake >/dev/null 2>&1 || true + fi + fi + first=$(hyprctl monitors -j 2>/dev/null \ + | ${pkgs.jq}/bin/jq -r '.[0].name // empty' 2>/dev/null || true) + [ -n "$first" ] && hyprctl dispatch focusmonitor "$first" >/dev/null 2>&1 || true + log "done enabled=$enabled first=''${first:-none}" + ''; +in +{ + displayDumpTool = dump; + displayWakeTool = wake; +} diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix index cea710d..3e93d8f 100644 --- a/modules/home/hyprland.nix +++ b/modules/home/hyprland.nix @@ -124,6 +124,10 @@ let profileWorkspaceRules = lib.mapAttrsToList monitorLib.workspaceRule activeProfile.workspaces; + # #127 dump tool first (no transition dep); wake is wired after transition. + displayDumpTool = + (import ./display-tools.nix { inherit pkgs; }).displayDumpTool; + # One transition primitive for the interactive Dock mode, abrupt undock, # and named profiles that disable the internal panel. Dock is one Hyprland # batch: enable the target, move every internal workspace, focus it, then @@ -233,8 +237,13 @@ let if ! output_enabled "$internal" 5; then log "transition=undock internal=$internal keyword=inert escalate=reload" hyprctl reload >/dev/null 2>&1 - output_enabled "$internal" 25 \ - || { log "transition=undock internal=$internal result=enable-failed"; exit 1; } + if ! output_enabled "$internal" 25; then + log "transition=undock internal=$internal result=enable-failed" + # #127: leave evidence on disk without SSH (TTY or next login). + ${displayDumpTool}/bin/nomarchy-display-dump undock-enable-failed \ + >/dev/null 2>&1 || true + exit 1 + fi # The config's monitor rules own the panel now. Re-assert the # runtime rule — which flushes normally, an output being enabled # again — so a config that parks the panel off cannot undo the @@ -246,6 +255,9 @@ let moves=$(all_workspaces | batch_moves "$internal") hyprctl --batch "$moves dispatch focusmonitor $internal" >/dev/null 2>&1 \ || { log "transition=undock internal=$internal result=move-failed"; exit 1; } + # #127: undock after idle DPMS left the seat black even when the + # panel was re-enabled — always light the chain after enable. + hyprctl dispatch dpms on >/dev/null 2>&1 || true log "transition=undock internal=$internal result=ok" ;; enable) internal="''${2:-}" @@ -255,13 +267,24 @@ let # `monitors` the proof, or the menu's "back on" notification cheers # for a keyword hyprctl merely said "ok" to. hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 - output_enabled "$internal" 10 ;; + if output_enabled "$internal" 10; then + hyprctl dispatch dpms on >/dev/null 2>&1 || true + true + else + false + fi ;; *) echo "usage: nomarchy-display-transition [dock [external-rule]|undock |enable ]" >&2 exit 64 ;; esac ''; + displayWakeTool = + (import ./display-tools.nix { + inherit pkgs; + displayTransition = displayTransitionTool; + }).displayWakeTool; + # Profile applier — on PATH only when profiles are declared (the menu # row self-gates on it). apply: the profile's rules live via hyprctl + # the in-flake state write; base: clear the state, then hyprctl reload @@ -1127,6 +1150,7 @@ in # debugging. The display-profile switcher itself remains gated so the # menu does not advertise an empty Profiles submenu. ++ lib.optionals config.nomarchy.hyprland.enable - ([ keyboardTool keyboardWatch displayTransitionTool displayProfileWatch ] + ([ keyboardTool keyboardWatch displayTransitionTool displayProfileWatch + displayDumpTool displayWakeTool ] ++ lib.optional (profiles != { }) displayProfileTool); } diff --git a/modules/home/idle.nix b/modules/home/idle.nix index 5bc869c..87eb4d1 100644 --- a/modules/home/idle.nix +++ b/modules/home/idle.nix @@ -19,6 +19,52 @@ let done exit 1 ''; + + # Same transition binary hyprland.nix puts on PATH — re-derived here so + # hypridle can pin absolute store paths (thin PATH in the user unit). + # Must stay in lockstep with modules/home/hyprland.nix's transition. + # We only need undock/enable + dpms; call via PATH-installed name is + # fragile, so wake embeds the transition from a minimal import chain: + # dump+wake live in display-tools.nix; transition is the public name + # on PATH after HM activation — wake script uses absolute path from + # a local reimplementation of just the enable/dpms rescue when the + # full transition package isn't injectable without circular imports. + # + # Practical approach: invoke `nomarchy-display-wake` from PATH after + # activation; for the unit Exec lines use a wrapper that prefers the + # store path of a wake script built with transition = null fallback + # (hyprctl keyword + reload + dpms) matching undock's public contract. + displayWake = + let + # Minimal undock-equivalent for idle's store path (no circular dep on + # hyprland.nix). Prefer the real transition when on PATH at runtime. + miniTransition = pkgs.writeShellScriptBin "nomarchy-display-transition" '' + set -u + case "''${1:-}" in + undock|enable) + internal="''${2:-}" + [ -n "$internal" ] || exit 64 + hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 || true + if ! hyprctl monitors -j 2>/dev/null \ + | ${pkgs.jq}/bin/jq -e --arg m "$internal" 'any(.[]; .name == $m)' \ + >/dev/null 2>&1; then + hyprctl reload >/dev/null 2>&1 || true + sleep 0.5 + fi + hyprctl keyword monitor "$internal,preferred,auto,1" >/dev/null 2>&1 || true + hyprctl dispatch dpms on >/dev/null 2>&1 || true + hyprctl monitors -j 2>/dev/null \ + | ${pkgs.jq}/bin/jq -e --arg m "$internal" 'any(.[]; .name == $m)' \ + >/dev/null 2>&1 + ;; + *) exit 64 ;; + esac + ''; + in + (import ./display-tools.nix { + inherit pkgs; + displayTransition = miniTransition; + }).displayWakeTool; in { config = lib.mkIf cfg.idle.enable { @@ -111,7 +157,10 @@ in # can't be safely dropped after the fact — killing the locker trips # its "go to a tty" crash failsafe). See nomarchy-lock-before-sleep # in modules/nixos/default.nix. - after_sleep_cmd = "hyprctl dispatch dpms on"; + # #127: not only dpms on — if dock mode left zero enabled + # outputs (eDP disabled + external gone/black), re-enable the + # internal. Absolute store path: hypridle's PATH is thin. + after_sleep_cmd = "${lib.getExe displayWake}"; }; listener = [ # Lock and screen-off are the same on either power source — @@ -120,7 +169,8 @@ in { timeout = 600; on-timeout = "hyprctl dispatch dpms off"; - on-resume = "hyprctl dispatch dpms on"; + # Same rescue path as after_sleep (#127). + on-resume = "${lib.getExe displayWake}"; } # Suspend only on battery, and sooner than the old fixed 30 min # (it now only fires unplugged). Plugged in, the machine stays diff --git a/modules/home/keybinds.nix b/modules/home/keybinds.nix index 9f95d3b..bd24b61 100644 --- a/modules/home/keybinds.nix +++ b/modules/home/keybinds.nix @@ -67,6 +67,10 @@ # place that decides how a lock actually looks. { mods = "$mod CTRL"; key = "L"; action = "exec, loginctl lock-session"; desc = "Lock screen"; group = "Menu"; } { mods = "$mod SHIFT"; key = "C"; action = "exec, hyprpicker -a"; desc = "Color picker (→ clipboard)"; group = "Menu"; } + # #127: dump display state to ~/nomarchy-display-dump-*.txt without SSH. + # If the seat is locked, hyprlock may swallow this — use Ctrl+Alt+F3 and + # run `nomarchy-display-dump` / `nomarchy-display-wake` on the TTY instead. + { mods = "$mod SHIFT"; key = "D"; action = "exec, nomarchy-display-dump keybind"; desc = "Dump display diagnostics (~/)"; group = "Menu"; } # Focus — SUPER + arrow keys. { mods = "$mod"; key = "left"; action = "movefocus, l"; desc = "Focus left"; group = "Window"; }