diff --git a/docs/superpowers/contracts/block-compulsive-opening-split-2026-08-21.json b/docs/superpowers/contracts/block-compulsive-opening-split-2026-08-21.json
new file mode 100644
index 00000000..41357baa
--- /dev/null
+++ b/docs/superpowers/contracts/block-compulsive-opening-split-2026-08-21.json
@@ -0,0 +1,20 @@
+{
+ "title": "Split block_compulsive_opening.sh under the 250-line cap",
+ "objective": "Bring linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh (705 lines) under the repo-wide 250-line file cap by moving its state, wrapper, install and reporting concerns into four sourced libs, preserving behaviour exactly and keeping the deployed /usr/local/bin entry path intact so already-installed app wrappers and the pacman hook continue to work.",
+ "acceptance_criteria": [
+ "The entry script and all four lib/cco_*.sh files are each at most 250 lines.",
+ "A line-multiset check against the pre-split file reports zero missing functional lines.",
+ "All three heredoc bodies -- the generated app wrapper, the generated pacman hook, and the help text -- are byte-identical to the pre-split versions, since they define deployed artifacts that verify_shell_split.sh cannot inspect.",
+ "status output is byte-identical to the still-monolithic deployed copy, and both exit 0.",
+ "The script runs correctly in BOTH layouts: lib/ beside the script (repo) and libs flat beside the script (deployed), and fails closed with exit 1 when a lib is missing.",
+ "install_all deploys the libs and uninstall_all removes them, in this same commit, so the deployed copy is never split-without-libs.",
+ "shellcheck passes without -x and shfmt -w is a no-op on all five files, with no suppression added."
+ ],
+ "out_of_scope": [
+ "Shell test coverage for the new libs -- no suite exists for this script; the split is verified behaviourally and textually.",
+ "Changing the deployed entry path or converting it to a symlink -- installed app wrappers hard-code /usr/local/bin/block-compulsive-opening.sh.",
+ "Deploying the libs under /usr/local/share/digital_wellbeing/lib, which install_leechblock_payload mirrors with rm -rf.",
+ "Any change to the app timeout policy, the hour-bucket semantics, or the managed app list."
+ ],
+ "verifier": "bash meta/scripts/check_file_length.sh --all for the cap; a python3 line-multiset diff and per-heredoc body diffs against HEAD for content equivalence; status output compared against the pre-split deployed copy; a flat-layout run plus a deleted-lib run for the two _CCO_LIB_DIR branches and the fail-closed guard; shellcheck, shfmt idempotence and bash -n on all five files; pre-commit run --files on the staged set."
+}
diff --git a/docs/superpowers/evidence/block-compulsive-opening-split-2026-08-21.json b/docs/superpowers/evidence/block-compulsive-opening-split-2026-08-21.json
new file mode 100644
index 00000000..18442494
--- /dev/null
+++ b/docs/superpowers/evidence/block-compulsive-opening-split-2026-08-21.json
@@ -0,0 +1,64 @@
+{
+ "intent": "Bring block_compulsive_opening.sh (705 lines) under the 250-line cap by moving its state, wrapper, install and reporting logic into four sourced libs, without changing behaviour and without breaking the already-installed app wrappers that hard-code the deployed script's path.",
+ "scope": [
+ "linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh: 705 -> 182 lines",
+ "New: lib/cco_install.sh (238), lib/cco_report.sh (102), lib/cco_state.sh (149), lib/cco_wrapper.sh (120)",
+ "install_all deploys the libs in the same commit, so there is no window where the deployed copy is split but its libs are missing",
+ "Non-goal: shell coverage for the new libs; no test suite exists for this script"
+ ],
+ "changes": [
+ "Split into four libs by concern: cco_state.sh (hour-bucket state files), cco_wrapper.sh (the launch gate), cco_install.sh (install/uninstall, the generated app wrapper and the pacman hook), cco_report.sh (status and help).",
+ "The entry script keeps its exact deployed path, /usr/local/bin/block-compulsive-opening.sh, and stays a real file there rather than a symlink. Already-installed app wrappers on disk hard-code that path, and the pacman hook at 96-restore-pkg-wrappers.hook invokes it; a symlink would also break dirname \"${BASH_SOURCE[0]}\" resolution.",
+ "_CCO_LIB_DIR probes for lib/ beside the script (repo tree) and falls back to the script's own directory (the flat /usr/local/bin deploy layout). Both branches are exercised below.",
+ "install_all copies the four libs flat into /usr/local/bin alongside the entry script, and uninstall_all removes them so no orphans are stranded. The copy runs even when the script is already at install_path, so a lib changed upstream still reaches the deployed copy that `rewrap-quiet` executes.",
+ "A guard before the source lines fails closed with an explicit message when any lib is unreadable, rather than dying on a bare `source` error.",
+ "Ran shfmt -w on the entry script, which removed a block of ~26 consecutive blank lines. No associative-array keys were affected: unlike install_leechblock.sh, this file's hyphenated keys (\"signal-desktop\") were already quoted, so the shfmt arithmetic-parsing bug does not apply here. Verified by grep after the run."
+ ],
+ "verification": [
+ {
+ "command": "bash meta/scripts/check_file_length.sh --all",
+ "result": "pass",
+ "evidence": "Over-cap list went from 4 files to 3; block_compulsive_opening.sh no longer listed. All five files 182/238/149/120/102 lines, under 250."
+ },
+ {
+ "command": "python3 line-multiset check of entry+4 libs against HEAD's block_compulsive_opening.sh",
+ "result": "pass",
+ "evidence": "MISSING functional lines: 0. Every non-comment, non-blank line from the pre-split file is accounted for across the five files."
+ },
+ {
+ "command": "Heredoc body extraction and diff for all three heredocs",
+ "result": "pass",
+ "evidence": "This is the check verify_shell_split.sh cannot perform, because it cannot see inside a heredoc body. WRAPPER_EOF (5 lines, the generated app wrapper), HOOK_EOF (14 lines, the generated pacman hook) and EOF (26 lines, the help text) are each byte-identical between HEAD and the split. The two deployed artifacts this script generates are therefore provably unchanged."
+ },
+ {
+ "command": "bash block_compulsive_opening.sh status vs /usr/local/bin/block-compulsive-opening.sh status",
+ "result": "pass",
+ "evidence": "Byte-identical output, both exit 0. The deployed copy is still the pre-split monolith and sha256-identical to HEAD, so this compares the split against the real pre-split behaviour rather than against itself. `help` differs only in the $0 invocation path, as expected."
+ },
+ {
+ "command": "Flat-layout test: entry script + libs copied into one directory (the deployed shape)",
+ "result": "pass",
+ "evidence": "status runs correctly with exit 0, confirming the _CCO_LIB_DIR fallback branch works in the /usr/local/bin layout, not just the repo tree."
+ },
+ {
+ "command": "Missing-lib guard: delete cco_report.sh from the flat layout and re-run",
+ "result": "pass",
+ "evidence": "Fails closed with 'Error: missing library cco_report.sh in
' and exit code 1 (measured directly, not through a pipeline). A partially-deployed install cannot look successful to the pacman hook."
+ },
+ {
+ "command": "shellcheck (no -x) + shfmt idempotence + bash -n on all five files",
+ "result": "pass",
+ "evidence": "shellcheck rc=0 with no suppressions; shfmt -w is a no-op on all five, so the pre-commit auto-format-and-re-stage cannot alter them; all five parse."
+ }
+ ],
+ "risks": [
+ "The deployed copy at /usr/local/bin/block-compulsive-opening.sh is still the pre-split monolith and keeps working untouched; nothing breaks until someone runs `sudo block-compulsive-opening.sh install`, which now deploys the libs too. Verified that the pacman hook's `rewrap-quiet` path does not call install_all, so the hook cannot trigger a partial deploy.",
+ "The libs deploy flat into /usr/local/bin rather than into /usr/local/share/digital_wellbeing/lib. That is deliberate: install_leechblock_payload does an `rm -rf` mirror of the latter, which would delete these libs on the next pacman-wrapper install.",
+ "No test suite exists for this script, so coverage is 0% and the verification above is behavioural and textual rather than test-driven."
+ ],
+ "rollback": [
+ "git revert this commit; block_compulsive_opening.sh returns to a single 705-line file and lib/cco_*.sh disappear.",
+ "If the split had already been deployed, re-run `sudo block-compulsive-opening.sh install` from the reverted tree to restore the monolith and remove the deployed libs.",
+ "Validate with: block-compulsive-opening.sh status (expect exit 0 and the per-app status table)."
+ ]
+}
diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh
index 3071ff2b..5fe9ff00 100755
--- a/linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh
+++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/block_compulsive_opening.sh
@@ -9,6 +9,48 @@
set -euo pipefail
+# The phases live in four libs. They are found in TWO layouts, because this
+# script runs from both:
+#
+# repo: /lib/cco_*.sh
+# deployed: /usr/local/bin/cco_*.sh (flat, beside the entry script)
+#
+# install_all copies the libs flat next to the installed entry script rather
+# than creating /usr/local/bin/lib, matching how pacman_lock_lib.sh and
+# heavy_job_lock.sh are already deployed there.
+#
+# readlink -f because /usr/bin/ wrappers exec this through its absolute
+# path and the installed copy may itself be reached via a symlink.
+#
+# This fails CLOSED: a missing lib aborts. The pacman fail-open pattern is
+# wrong here — a half-loaded blocker that silently stops blocking is the exact
+# failure this tool exists to prevent, and unlike pacman you do not need this
+# script to repair it.
+_CCO_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
+if [[ -d "$_CCO_DIR/lib" ]]; then
+ _CCO_LIB_DIR="$_CCO_DIR/lib"
+else
+ _CCO_LIB_DIR="$_CCO_DIR"
+fi
+
+for _cco_lib in cco_state cco_wrapper cco_install cco_report; do
+ if [[ ! -r "$_CCO_LIB_DIR/${_cco_lib}.sh" ]]; then
+ echo "Error: missing library ${_cco_lib}.sh in $_CCO_LIB_DIR" >&2
+ echo "The installation is incomplete; re-run: sudo $0 install" >&2
+ exit 1
+ fi
+done
+unset _cco_lib
+
+# shellcheck source=lib/cco_state.sh
+. "$_CCO_LIB_DIR/cco_state.sh"
+# shellcheck source=lib/cco_wrapper.sh
+. "$_CCO_LIB_DIR/cco_wrapper.sh"
+# shellcheck source=lib/cco_install.sh
+. "$_CCO_LIB_DIR/cco_install.sh"
+# shellcheck source=lib/cco_report.sh
+. "$_CCO_LIB_DIR/cco_report.sh"
+
# Send desktop notification (inlined from common.sh to avoid dependency issues
# when script is installed to /usr/local/bin)
notify() {
@@ -79,571 +121,6 @@ declare -A PROCESS_MATCH=(
["discord"]="$_cco_home/.config/discord/"
)
-# Ensure state directory exists
-ensure_state_dir() {
- mkdir -p "$STATE_DIR" 2>/dev/null || true
-}
-
-# Log message with timestamp
-log_message() {
- local msg
- msg="$(printf '%(%Y-%m-%d %H:%M:%S)T' -1) - $1"
- echo "$msg" >&2
- echo "$msg" >>"$LOG_FILE" 2>/dev/null || true
-}
-
-# Get current hour key (YYYY-MM-DD-HH format)
-get_hour_key() {
- printf '%(%Y-%m-%d-%H)T' -1
-}
-
-# Get state file path for an app
-get_state_file() {
- local app="$1"
- echo "$STATE_DIR/${app}.lastopen"
-}
-
-# Check if app was already opened this hour
-was_opened_this_hour() {
- local app="$1"
- local state_file
- state_file=$(get_state_file "$app")
- local current_hour
- current_hour=$(get_hour_key)
-
- if [[ -f $state_file ]]; then
- local last_hour
- last_hour=$(cat "$state_file" 2>/dev/null || echo "")
- if [[ $last_hour == "$current_hour" ]]; then
- return 0 # Was opened this hour
- fi
- fi
- return 1 # Not opened this hour
-}
-
-# Record app opening
-record_opening() {
- local app="$1"
- local state_file
- state_file=$(get_state_file "$app")
- local current_hour
- current_hour=$(get_hour_key)
-
- echo "$current_hour" >"$state_file"
- log_message "ALLOWED: $app opened (first time this hour: $current_hour)"
-}
-
-# Block app and notify
-block_app() {
- local app="$1"
- local current_hour
- current_hour=$(get_hour_key)
-
- log_message "BLOCKED: $app launch prevented (already opened this hour: $current_hour)"
-
- # Send notification using common library
- notify "🚫 $app Blocked" "Already opened this hour. Wait until the next hour." critical 5000
-}
-
-# Get real binary path for an app
-get_real_binary() {
- local app="$1"
- local wrapper_path="${APPS[$app]}"
- local real_binary="${REAL_BINARIES[$app]}"
-
- # Check if wrapper is installed (original moved to .orig)
- if [[ -f "${wrapper_path}.orig" ]]; then
- # Wrapper installed, return the actual executable
- echo "$real_binary"
- return 0
- fi
-
- return 1
-}
-
-# Get running state file path for an app (tracks PID and start time)
-get_running_file() {
- local app="$1"
- echo "$STATE_DIR/${app}.running"
-}
-
-# Clean up stale running state (process no longer running)
-# Uses process-name matching so Electron apps that fork don't appear stale.
-cleanup_stale_running_state() {
- local app="$1"
- local running_file
- running_file=$(get_running_file "$app")
-
- if [[ ! -f $running_file ]]; then
- return 0
- fi
-
- # Match on PROCESS_MATCH when the exec target is a launcher that exec()s away
- # (see the PROCESS_MATCH map); otherwise the exec target is the process.
- local real_binary="${PROCESS_MATCH[$app]:-${REAL_BINARIES[$app]}}"
-
- # Check if any process matching the real binary is still running
- if ! is_app_running "$real_binary"; then
- log_message "CLEANUP: Stale running state for $app (no matching processes found)"
- rm -f "$running_file"
- fi
-}
-
-# Check if app is running by process name (handles Electron apps that fork)
-is_app_running() {
- local real_binary="$1"
- pgrep -f "$real_binary" >/dev/null 2>&1
-}
-
-# Kill all processes matching the real binary path
-kill_app() {
- local real_binary="$1"
- pkill -f "$real_binary" 2>/dev/null || true
- sleep 2
- pkill -9 -f "$real_binary" 2>/dev/null || true
-}
-
-# Check if auto-close is suspended for an app today
-is_autoclose_suspended() {
- local app="$1"
- local today
- today=$(printf '%(%Y-%m-%d)T' -1)
- local suspend_file="$STATE_DIR/${app}.suspend-autoclose"
-
- if [[ -f $suspend_file ]]; then
- local suspend_date
- suspend_date=$(cat "$suspend_file" 2>/dev/null || echo "")
- if [[ $suspend_date == "$today" ]]; then
- return 0 # Suspended for today
- else
- # Stale suspend file, clean up
- rm -f "$suspend_file"
- fi
- fi
- return 1
-}
-
-# Launch app with auto-close timer
-launch_with_timer() {
- local app="$1"
- local real_binary="$2"
- shift 2
-
- # $real_binary is what we EXEC; $proc_match is what we look for in the process
- # table. They are usually identical, but NOT when the exec target is a launcher
- # that exec()s away: discord's /usr/bin/discord.orig replaces itself with
- # ~/.config/discord//Discord, so matching/killing on the launcher path
- # would find nothing — the auto-close daemon would think the app exited
- # instantly and the time limit would silently never fire.
- local proc_match="${PROCESS_MATCH[$app]:-$real_binary}"
-
- # Check if auto-close is suspended for today
- if is_autoclose_suspended "$app"; then
- log_message "LAUNCHED: $app (auto-close suspended for today)"
- exec "$real_binary" "$@"
- fi
-
- # Use per-app timeout if set, otherwise fall back to global default
- local timeout_minutes="${APP_TIMEOUT_MINUTES[$app]:-$AUTO_CLOSE_TIMEOUT_MINUTES}"
- local warning_seconds=$(((timeout_minutes - AUTO_CLOSE_WARNING_MINUTES) * 60))
- local running_file
- running_file=$(get_running_file "$app")
-
- # Launch the app in background
- "$real_binary" "$@" &
- local app_pid=$!
-
- # Give Electron apps time to fork before we start polling
- sleep 2
-
- # Record state (FORK-FREE: use printf %s for timestamp)
- echo "$app_pid $(printf '%(%s)T' -1)" >"$running_file"
- log_message "LAUNCHED: $app with PID $app_pid (auto-close in ${timeout_minutes}m)"
-
- # Spawn the auto-close daemon in a completely detached subshell
- # Uses process-name matching so it works for Electron apps that fork on launch
- (
- # Detach from terminal
- exec /dev/null 2>&1
-
- # Wait for warning time
- sleep "$warning_seconds"
-
- # Check if still running before warning (by process name, not PID)
- if is_app_running "$proc_match"; then
- # Send warning notification
- notify-send -u critical -t 30000 "⏰ $app Closing Soon" \
- "Session will end in ${AUTO_CLOSE_WARNING_MINUTES} minutes. Save your work!" 2>/dev/null || true
- else
- # Process already exited
- rm -f "$running_file" 2>/dev/null || true
- exit 0
- fi
-
- # Wait remaining time
- sleep $((AUTO_CLOSE_WARNING_MINUTES * 60))
-
- # Check if still running (by process name)
- if is_app_running "$proc_match"; then
- # Send final notification
- notify-send -u critical -t 5000 "🚫 $app Session Ended" \
- "Time's up! Closing $app now." 2>/dev/null || true
-
- # Kill all matching processes (handles forked Electron children)
- kill_app "$proc_match"
-
- printf '%(%Y-%m-%d %H:%M:%S)T - AUTO-CLOSED: %s after %dm\n' -1 "$app" "${timeout_minutes}" >>"$LOG_FILE" 2>/dev/null || true
- fi
-
- rm -f "$running_file" 2>/dev/null || true
- ) &
- disown
-
- # Wait for the app to exit by polling process name.
- # Electron apps fork immediately so waiting on $app_pid would return too soon.
- while is_app_running "$proc_match"; do
- sleep 5
- done
-
- # Clean up running state
- rm -f "$running_file" 2>/dev/null || true
-
- log_message "EXITED: $app"
-}
-
-# Main wrapper function - called when wrapping app launches
-wrapper_main() {
- local app="$1"
- shift
-
- ensure_state_dir
-
- local real_binary
- if ! real_binary=$(get_real_binary "$app"); then
- log_message "ERROR: Real binary not found for $app"
- echo "Error: Real binary for $app not found. Was the installer run?" >&2
- exit 1
- fi
-
- # Clean up stale running state from previous crashes
- cleanup_stale_running_state "$app"
-
- if was_opened_this_hour "$app"; then
- block_app "$app"
- exit 1
- fi
-
- record_opening "$app"
-
- # Launch with auto-close timer (replaces direct exec)
- launch_with_timer "$app" "$real_binary" "$@"
-}
-
-# Install wrapper for a specific app
-install_wrapper() {
- local app="$1"
- local wrapper_path="${APPS[$app]}"
- local real_binary="${REAL_BINARIES[$app]}"
-
- # Check if already wrapped: .orig must exist AND current file must be our wrapper
- if [[ -f "${wrapper_path}.orig" ]]; then
- if grep -q "block-compulsive-opening" "$wrapper_path" 2>/dev/null; then
- echo " ✓ $app already wrapped"
- return 0
- else
- # .orig exists but wrapper was overwritten (e.g. by package update)
- echo " ↻ $app wrapper was overwritten, re-installing..."
- rm -f "${wrapper_path}.orig"
- # Fall through to re-install
- fi
- fi
-
- # Check if wrapper location exists (file or symlink)
- if [[ ! -e $wrapper_path && ! -L $wrapper_path ]]; then
- echo " ⚠ $app not installed ($wrapper_path not found)"
- return 1
- fi
-
- # Check if real binary exists.
- # Special case: when the exec target IS this wrapper's own backup
- # ("${wrapper_path}.orig") the app has no separate system binary — the
- # launcher itself is the real thing, and the wrap below CREATES the .orig by
- # moving it aside. Requiring it to exist beforehand is a chicken-and-egg that
- # could never pass. The wrapper_path check above already proved it installed.
- if [[ $real_binary != "${wrapper_path}.orig" && ! -x $real_binary ]]; then
- echo " ⚠ $app real binary not found ($real_binary)"
- return 1
- fi
-
- echo " Installing wrapper for $app..."
-
- # Handle symlinks: save the symlink itself, not the target
- if [[ -L $wrapper_path ]]; then
- local link_target
- link_target=$(readlink "$wrapper_path")
- echo " Saving symlink $wrapper_path -> $link_target as ${wrapper_path}.orig"
- # Remove symlink and create .orig that stores the link target info
- echo "SYMLINK:$link_target" >"${wrapper_path}.orig"
- rm "$wrapper_path"
- else
- echo " Backing up $wrapper_path -> ${wrapper_path}.orig"
- mv "$wrapper_path" "${wrapper_path}.orig"
- fi
-
- echo " Creating wrapper at $wrapper_path"
- cat >"$wrapper_path" </dev/null || echo "")
- if [[ $orig_content == SYMLINK:* ]]; then
- local link_target="${orig_content#SYMLINK:}"
- echo " Restoring symlink $wrapper_path -> $link_target"
- ln -s "$link_target" "$wrapper_path"
- rm "${wrapper_path}.orig"
- else
- echo " Restoring original file"
- mv "${wrapper_path}.orig" "$wrapper_path"
- fi
- echo " ✓ $app restored"
-}
-
-# Install all wrappers
-install_all() {
- echo "Installing compulsive opening blockers..."
- echo ""
-
- # Install main script to /usr/local/bin
- local script_path
- script_path="$(readlink -f "$0")"
- local install_path="/usr/local/bin/block-compulsive-opening.sh"
-
- if [[ $script_path != "$install_path" ]]; then
- echo "Installing main script to $install_path..."
- cp "$script_path" "$install_path"
- chmod +x "$install_path"
- echo "✓ Main script installed"
- else
- echo "Main script already at $install_path"
- fi
- echo ""
-
- # Install wrappers for each app
- local installed=0
- for app in "${!APPS[@]}"; do
- if install_wrapper "$app"; then
- ((installed++)) || true
- fi
- done
-
- echo ""
- echo "Installation complete. $installed app(s) wrapped."
- echo ""
- echo "Each app can now only be opened once per hour."
- echo "State files stored in: $STATE_DIR"
- echo "Logs stored in: $LOG_FILE"
-
- # Install pacman hook to re-wrap after package updates
- install_pacman_hook
-}
-
-# Install pacman hook to re-install wrappers after package updates
-install_pacman_hook() {
- local hook_dir="/etc/pacman.d/hooks"
- local hook_file="$hook_dir/95-compulsive-block-rewrap.hook"
-
- echo ""
- echo "Installing pacman hook..."
-
- mkdir -p "$hook_dir"
-
- cat >"$hook_file" <<'HOOK_EOF'
-[Trigger]
-Operation = Upgrade
-Operation = Install
-Type = Package
-Target = beeper
-Target = signal-desktop
-Target = discord
-
-[Action]
-Description = Re-installing compulsive opening blockers after package update
-When = PostTransaction
-Exec = /usr/local/bin/block-compulsive-opening.sh rewrap-quiet
-HOOK_EOF
-
- chmod 644 "$hook_file"
- echo "✓ Pacman hook installed: $hook_file"
- echo " Wrappers will be automatically re-installed after beeper/signal/discord updates"
-}
-
-# Uninstall pacman hook
-uninstall_pacman_hook() {
- local hook_file="/etc/pacman.d/hooks/95-compulsive-block-rewrap.hook"
- if [[ -f $hook_file ]]; then
- rm -f "$hook_file"
- echo "✓ Pacman hook removed"
- fi
-}
-
-# Quietly re-wrap apps (for pacman hook - no interactive output)
-rewrap_quiet() {
- log_message "REWRAP: Pacman hook triggered, re-installing wrappers"
-
- for app in "${!APPS[@]}"; do
- local wrapper_path="${APPS[$app]}"
-
- # Re-wrap if wrapper is missing or was overwritten by a package update
- if [[ ! -f "${wrapper_path}.orig" ]] || ! grep -q "block-compulsive-opening" "$wrapper_path" 2>/dev/null; then
- log_message "REWRAP: $app wrapper missing or overwritten, re-installing"
- rm -f "${wrapper_path}.orig"
- install_wrapper "$app" >>"$LOG_FILE" 2>&1 || true
- fi
- done
-
- log_message "REWRAP: Complete"
-}
-
-# Uninstall all wrappers
-uninstall_all() {
- echo "Removing compulsive opening blockers..."
- echo ""
-
- for app in "${!APPS[@]}"; do
- uninstall_wrapper "$app" || true
- done
-
- rm -f "/usr/local/bin/block-compulsive-opening.sh"
-
- # Remove pacman hook
- uninstall_pacman_hook
-
- echo ""
- echo "Uninstallation complete."
-}
-
-# Show status of all apps
-show_status() {
- ensure_state_dir
- local current_hour
- current_hour=$(get_hour_key)
-
- echo "Compulsive Opening Blocker Status"
- echo "=================================="
- echo "Current hour: $current_hour"
- echo ""
-
- for app in "${!APPS[@]}"; do
- local state_file
- state_file=$(get_state_file "$app")
- local status="not opened this hour"
- local icon="○"
-
- if [[ -f $state_file ]]; then
- local last_hour
- last_hour=$(cat "$state_file" 2>/dev/null || echo "")
- if [[ $last_hour == "$current_hour" ]]; then
- status="already opened (blocked until next hour)"
- icon="●"
- else
- status="last opened: $last_hour"
- fi
- fi
-
- # Check if wrapped
- local wrapped="not installed"
- local wrapper_path="${APPS[$app]}"
- if [[ -f "${wrapper_path}.orig" ]]; then
- wrapped="wrapped"
- elif [[ -f $wrapper_path ]]; then
- wrapped="installed (not wrapped)"
- fi
-
- printf " %s %-15s [%s] - %s\n" "$icon" "$app" "$wrapped" "$status"
- done
-
- echo ""
- echo "State directory: $STATE_DIR"
-}
-
-# Reset state for an app (allow opening again)
-reset_app() {
- local app="$1"
- local state_file
- state_file=$(get_state_file "$app")
-
- if [[ -f $state_file ]]; then
- rm -f "$state_file"
- echo "Reset $app - can be opened again this hour"
- log_message "RESET: $app state cleared by user"
- else
- echo "$app was not marked as opened"
- fi
-}
-
-# Clear all state
-reset_all() {
- ensure_state_dir
- rm -f "$STATE_DIR"/*.lastopen
- echo "All apps reset - can be opened again this hour"
- log_message "RESET: All app states cleared by user"
-}
-
-# Show usage
-show_usage() {
- cat < - Reset an app to allow opening again this hour
- reset-all - Reset all apps
- wrapper [args] - Run as wrapper for an app (internal use)
- help - Show this help message
-
-Managed Apps:
- beeper - Beeper messaging client
- signal-desktop - Signal messenger
- discord - Discord chat
-
-Examples:
- sudo $0 install # Install all wrappers
- $0 status # Check which apps were opened this hour
- $0 reset discord # Allow Discord to be opened again
-
-EOF
-}
-
# Main entry point
main() {
case "${1:-help}" in
diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_install.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_install.sh
new file mode 100755
index 00000000..4550bb03
--- /dev/null
+++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_install.sh
@@ -0,0 +1,238 @@
+#!/bin/bash
+# Install/uninstall of the per-app wrappers, the flat lib deployment and the
+# pacman rewrap hook for block_compulsive_opening.sh.
+# Sourced by the entry script; inherits its strict mode and globals.
+
+# Install wrapper for a specific app
+install_wrapper() {
+ local app="$1"
+ local wrapper_path="${APPS[$app]}"
+ local real_binary="${REAL_BINARIES[$app]}"
+
+ # Check if already wrapped: .orig must exist AND current file must be our wrapper
+ if [[ -f "${wrapper_path}.orig" ]]; then
+ if grep -q "block-compulsive-opening" "$wrapper_path" 2>/dev/null; then
+ echo " ✓ $app already wrapped"
+ return 0
+ else
+ # .orig exists but wrapper was overwritten (e.g. by package update)
+ echo " ↻ $app wrapper was overwritten, re-installing..."
+ rm -f "${wrapper_path}.orig"
+ # Fall through to re-install
+ fi
+ fi
+
+ # Check if wrapper location exists (file or symlink)
+ if [[ ! -e $wrapper_path && ! -L $wrapper_path ]]; then
+ echo " ⚠ $app not installed ($wrapper_path not found)"
+ return 1
+ fi
+
+ # Check if real binary exists.
+ # Special case: when the exec target IS this wrapper's own backup
+ # ("${wrapper_path}.orig") the app has no separate system binary — the
+ # launcher itself is the real thing, and the wrap below CREATES the .orig by
+ # moving it aside. Requiring it to exist beforehand is a chicken-and-egg that
+ # could never pass. The wrapper_path check above already proved it installed.
+ if [[ $real_binary != "${wrapper_path}.orig" && ! -x $real_binary ]]; then
+ echo " ⚠ $app real binary not found ($real_binary)"
+ return 1
+ fi
+
+ echo " Installing wrapper for $app..."
+
+ # Handle symlinks: save the symlink itself, not the target
+ if [[ -L $wrapper_path ]]; then
+ local link_target
+ link_target=$(readlink "$wrapper_path")
+ echo " Saving symlink $wrapper_path -> $link_target as ${wrapper_path}.orig"
+ # Remove symlink and create .orig that stores the link target info
+ echo "SYMLINK:$link_target" >"${wrapper_path}.orig"
+ rm "$wrapper_path"
+ else
+ echo " Backing up $wrapper_path -> ${wrapper_path}.orig"
+ mv "$wrapper_path" "${wrapper_path}.orig"
+ fi
+
+ echo " Creating wrapper at $wrapper_path"
+ cat >"$wrapper_path" </dev/null || echo "")
+ if [[ $orig_content == SYMLINK:* ]]; then
+ local link_target="${orig_content#SYMLINK:}"
+ echo " Restoring symlink $wrapper_path -> $link_target"
+ ln -s "$link_target" "$wrapper_path"
+ rm "${wrapper_path}.orig"
+ else
+ echo " Restoring original file"
+ mv "${wrapper_path}.orig" "$wrapper_path"
+ fi
+ echo " ✓ $app restored"
+}
+
+# Install all wrappers
+install_all() {
+ echo "Installing compulsive opening blockers..."
+ echo ""
+
+ # Install main script to /usr/local/bin
+ local script_path
+ script_path="$(readlink -f "$0")"
+ local install_path="/usr/local/bin/block-compulsive-opening.sh"
+
+ if [[ $script_path != "$install_path" ]]; then
+ echo "Installing main script to $install_path..."
+ cp "$script_path" "$install_path"
+ chmod +x "$install_path"
+ echo "✓ Main script installed"
+ else
+ echo "Main script already at $install_path"
+ fi
+
+ # The libs travel with the entry script, FLAT beside it rather than in a
+ # lib/ subdirectory — the same shape as /usr/local/bin/pacman_lock_lib.sh
+ # and heavy_job_lock.sh. The entry script probes for lib/ first and falls
+ # back to its own directory, so both layouts work.
+ #
+ # This must run even when the script is already at $install_path: a lib
+ # added or changed upstream still has to reach /usr/local/bin, and
+ # `rewrap-quiet` from the pacman hook runs the DEPLOYED copy, which would
+ # otherwise keep sourcing stale libs.
+ local lib_src="$_CCO_LIB_DIR"
+ local lib_dest="/usr/local/bin"
+ if [[ $lib_src != "$lib_dest" ]]; then
+ echo "Installing libraries to $lib_dest..."
+ local lib
+ for lib in cco_state cco_wrapper cco_install cco_report; do
+ cp "$lib_src/${lib}.sh" "$lib_dest/${lib}.sh"
+ chmod +x "$lib_dest/${lib}.sh"
+ done
+ echo "✓ Libraries installed"
+ fi
+ echo ""
+
+ # Install wrappers for each app
+ local installed=0
+ for app in "${!APPS[@]}"; do
+ if install_wrapper "$app"; then
+ ((installed++)) || true
+ fi
+ done
+
+ echo ""
+ echo "Installation complete. $installed app(s) wrapped."
+ echo ""
+ echo "Each app can now only be opened once per hour."
+ echo "State files stored in: $STATE_DIR"
+ echo "Logs stored in: $LOG_FILE"
+
+ # Install pacman hook to re-wrap after package updates
+ install_pacman_hook
+}
+
+# Install pacman hook to re-install wrappers after package updates
+install_pacman_hook() {
+ local hook_dir="/etc/pacman.d/hooks"
+ local hook_file="$hook_dir/95-compulsive-block-rewrap.hook"
+
+ echo ""
+ echo "Installing pacman hook..."
+
+ mkdir -p "$hook_dir"
+
+ cat >"$hook_file" <<'HOOK_EOF'
+[Trigger]
+Operation = Upgrade
+Operation = Install
+Type = Package
+Target = beeper
+Target = signal-desktop
+Target = discord
+
+[Action]
+Description = Re-installing compulsive opening blockers after package update
+When = PostTransaction
+Exec = /usr/local/bin/block-compulsive-opening.sh rewrap-quiet
+HOOK_EOF
+
+ chmod 644 "$hook_file"
+ echo "✓ Pacman hook installed: $hook_file"
+ echo " Wrappers will be automatically re-installed after beeper/signal/discord updates"
+}
+
+# Uninstall pacman hook
+uninstall_pacman_hook() {
+ local hook_file="/etc/pacman.d/hooks/95-compulsive-block-rewrap.hook"
+ if [[ -f $hook_file ]]; then
+ rm -f "$hook_file"
+ echo "✓ Pacman hook removed"
+ fi
+}
+
+# Quietly re-wrap apps (for pacman hook - no interactive output)
+rewrap_quiet() {
+ log_message "REWRAP: Pacman hook triggered, re-installing wrappers"
+
+ for app in "${!APPS[@]}"; do
+ local wrapper_path="${APPS[$app]}"
+
+ # Re-wrap if wrapper is missing or was overwritten by a package update
+ if [[ ! -f "${wrapper_path}.orig" ]] || ! grep -q "block-compulsive-opening" "$wrapper_path" 2>/dev/null; then
+ log_message "REWRAP: $app wrapper missing or overwritten, re-installing"
+ rm -f "${wrapper_path}.orig"
+ install_wrapper "$app" >>"$LOG_FILE" 2>&1 || true
+ fi
+ done
+
+ log_message "REWRAP: Complete"
+}
+
+# Uninstall all wrappers
+uninstall_all() {
+ echo "Removing compulsive opening blockers..."
+ echo ""
+
+ for app in "${!APPS[@]}"; do
+ uninstall_wrapper "$app" || true
+ done
+
+ rm -f "/usr/local/bin/block-compulsive-opening.sh"
+
+ # The libs install_all copied flat beside the entry script go with it;
+ # leaving them behind would strand four orphans in /usr/local/bin.
+ local lib
+ for lib in cco_state cco_wrapper cco_install cco_report; do
+ rm -f "/usr/local/bin/${lib}.sh"
+ done
+
+ # Remove pacman hook
+ uninstall_pacman_hook
+
+ echo ""
+ echo "Uninstallation complete."
+}
diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_report.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_report.sh
new file mode 100755
index 00000000..046932ee
--- /dev/null
+++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_report.sh
@@ -0,0 +1,102 @@
+#!/bin/bash
+# Status reporting and reset commands for block_compulsive_opening.sh.
+# Sourced by the entry script; inherits its strict mode and globals.
+
+# Show status of all apps
+show_status() {
+ ensure_state_dir
+ local current_hour
+ current_hour=$(get_hour_key)
+
+ echo "Compulsive Opening Blocker Status"
+ echo "=================================="
+ echo "Current hour: $current_hour"
+ echo ""
+
+ for app in "${!APPS[@]}"; do
+ local state_file
+ state_file=$(get_state_file "$app")
+ local status="not opened this hour"
+ local icon="○"
+
+ if [[ -f $state_file ]]; then
+ local last_hour
+ last_hour=$(cat "$state_file" 2>/dev/null || echo "")
+ if [[ $last_hour == "$current_hour" ]]; then
+ status="already opened (blocked until next hour)"
+ icon="●"
+ else
+ status="last opened: $last_hour"
+ fi
+ fi
+
+ # Check if wrapped
+ local wrapped="not installed"
+ local wrapper_path="${APPS[$app]}"
+ if [[ -f "${wrapper_path}.orig" ]]; then
+ wrapped="wrapped"
+ elif [[ -f $wrapper_path ]]; then
+ wrapped="installed (not wrapped)"
+ fi
+
+ printf " %s %-15s [%s] - %s\n" "$icon" "$app" "$wrapped" "$status"
+ done
+
+ echo ""
+ echo "State directory: $STATE_DIR"
+}
+
+# Reset state for an app (allow opening again)
+reset_app() {
+ local app="$1"
+ local state_file
+ state_file=$(get_state_file "$app")
+
+ if [[ -f $state_file ]]; then
+ rm -f "$state_file"
+ echo "Reset $app - can be opened again this hour"
+ log_message "RESET: $app state cleared by user"
+ else
+ echo "$app was not marked as opened"
+ fi
+}
+
+# Clear all state
+reset_all() {
+ ensure_state_dir
+ rm -f "$STATE_DIR"/*.lastopen
+ echo "All apps reset - can be opened again this hour"
+ log_message "RESET: All app states cleared by user"
+}
+
+# Show usage
+show_usage() {
+ cat < - Reset an app to allow opening again this hour
+ reset-all - Reset all apps
+ wrapper [args] - Run as wrapper for an app (internal use)
+ help - Show this help message
+
+Managed Apps:
+ beeper - Beeper messaging client
+ signal-desktop - Signal messenger
+ discord - Discord chat
+
+Examples:
+ sudo $0 install # Install all wrappers
+ $0 status # Check which apps were opened this hour
+ $0 reset discord # Allow Discord to be opened again
+
+EOF
+}
diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_state.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_state.sh
new file mode 100755
index 00000000..d01a6e97
--- /dev/null
+++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_state.sh
@@ -0,0 +1,149 @@
+#!/bin/bash
+# State, timing and process helpers for block_compulsive_opening.sh.
+# Sourced by the entry script; inherits its strict mode, its notify() helper
+# and the STATE_DIR/LOG_FILE/APPS/REAL_BINARIES/PROCESS_MATCH globals it
+# defines.
+
+# Ensure state directory exists
+ensure_state_dir() {
+ mkdir -p "$STATE_DIR" 2>/dev/null || true
+}
+
+# Log message with timestamp
+log_message() {
+ local msg
+ msg="$(printf '%(%Y-%m-%d %H:%M:%S)T' -1) - $1"
+ echo "$msg" >&2
+ echo "$msg" >>"$LOG_FILE" 2>/dev/null || true
+}
+
+# Get current hour key (YYYY-MM-DD-HH format)
+get_hour_key() {
+ printf '%(%Y-%m-%d-%H)T' -1
+}
+
+# Get state file path for an app
+get_state_file() {
+ local app="$1"
+ echo "$STATE_DIR/${app}.lastopen"
+}
+
+# Check if app was already opened this hour
+was_opened_this_hour() {
+ local app="$1"
+ local state_file
+ state_file=$(get_state_file "$app")
+ local current_hour
+ current_hour=$(get_hour_key)
+
+ if [[ -f $state_file ]]; then
+ local last_hour
+ last_hour=$(cat "$state_file" 2>/dev/null || echo "")
+ if [[ $last_hour == "$current_hour" ]]; then
+ return 0 # Was opened this hour
+ fi
+ fi
+ return 1 # Not opened this hour
+}
+
+# Record app opening
+record_opening() {
+ local app="$1"
+ local state_file
+ state_file=$(get_state_file "$app")
+ local current_hour
+ current_hour=$(get_hour_key)
+
+ echo "$current_hour" >"$state_file"
+ log_message "ALLOWED: $app opened (first time this hour: $current_hour)"
+}
+
+# Block app and notify
+block_app() {
+ local app="$1"
+ local current_hour
+ current_hour=$(get_hour_key)
+
+ log_message "BLOCKED: $app launch prevented (already opened this hour: $current_hour)"
+
+ # Send notification using common library
+ notify "🚫 $app Blocked" "Already opened this hour. Wait until the next hour." critical 5000
+}
+
+# Get real binary path for an app
+get_real_binary() {
+ local app="$1"
+ local wrapper_path="${APPS[$app]}"
+ local real_binary="${REAL_BINARIES[$app]}"
+
+ # Check if wrapper is installed (original moved to .orig)
+ if [[ -f "${wrapper_path}.orig" ]]; then
+ # Wrapper installed, return the actual executable
+ echo "$real_binary"
+ return 0
+ fi
+
+ return 1
+}
+
+# Get running state file path for an app (tracks PID and start time)
+get_running_file() {
+ local app="$1"
+ echo "$STATE_DIR/${app}.running"
+}
+
+# Clean up stale running state (process no longer running)
+# Uses process-name matching so Electron apps that fork don't appear stale.
+cleanup_stale_running_state() {
+ local app="$1"
+ local running_file
+ running_file=$(get_running_file "$app")
+
+ if [[ ! -f $running_file ]]; then
+ return 0
+ fi
+
+ # Match on PROCESS_MATCH when the exec target is a launcher that exec()s away
+ # (see the PROCESS_MATCH map); otherwise the exec target is the process.
+ local real_binary="${PROCESS_MATCH[$app]:-${REAL_BINARIES[$app]}}"
+
+ # Check if any process matching the real binary is still running
+ if ! is_app_running "$real_binary"; then
+ log_message "CLEANUP: Stale running state for $app (no matching processes found)"
+ rm -f "$running_file"
+ fi
+}
+
+# Check if app is running by process name (handles Electron apps that fork)
+is_app_running() {
+ local real_binary="$1"
+ pgrep -f "$real_binary" >/dev/null 2>&1
+}
+
+# Kill all processes matching the real binary path
+kill_app() {
+ local real_binary="$1"
+ pkill -f "$real_binary" 2>/dev/null || true
+ sleep 2
+ pkill -9 -f "$real_binary" 2>/dev/null || true
+}
+
+# Check if auto-close is suspended for an app today
+is_autoclose_suspended() {
+ local app="$1"
+ local today
+ today=$(printf '%(%Y-%m-%d)T' -1)
+ local suspend_file="$STATE_DIR/${app}.suspend-autoclose"
+
+ if [[ -f $suspend_file ]]; then
+ local suspend_date
+ suspend_date=$(cat "$suspend_file" 2>/dev/null || echo "")
+ if [[ $suspend_date == "$today" ]]; then
+ return 0 # Suspended for today
+ else
+ # Stale suspend file, clean up
+ rm -f "$suspend_file"
+ fi
+ fi
+ return 1
+}
diff --git a/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_wrapper.sh b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_wrapper.sh
new file mode 100755
index 00000000..2181d838
--- /dev/null
+++ b/linux_configuration/scripts/periodic_background/digital_wellbeing/lib/cco_wrapper.sh
@@ -0,0 +1,120 @@
+#!/bin/bash
+# App-launch path for block_compulsive_opening.sh: the auto-close timer and the
+# wrapper entry that the generated /usr/bin/ shims exec into.
+# Sourced by the entry script; inherits its strict mode and globals.
+
+# Launch app with auto-close timer
+launch_with_timer() {
+ local app="$1"
+ local real_binary="$2"
+ shift 2
+
+ # $real_binary is what we EXEC; $proc_match is what we look for in the process
+ # table. They are usually identical, but NOT when the exec target is a launcher
+ # that exec()s away: discord's /usr/bin/discord.orig replaces itself with
+ # ~/.config/discord//Discord, so matching/killing on the launcher path
+ # would find nothing — the auto-close daemon would think the app exited
+ # instantly and the time limit would silently never fire.
+ local proc_match="${PROCESS_MATCH[$app]:-$real_binary}"
+
+ # Check if auto-close is suspended for today
+ if is_autoclose_suspended "$app"; then
+ log_message "LAUNCHED: $app (auto-close suspended for today)"
+ exec "$real_binary" "$@"
+ fi
+
+ # Use per-app timeout if set, otherwise fall back to global default
+ local timeout_minutes="${APP_TIMEOUT_MINUTES[$app]:-$AUTO_CLOSE_TIMEOUT_MINUTES}"
+ local warning_seconds=$(((timeout_minutes - AUTO_CLOSE_WARNING_MINUTES) * 60))
+ local running_file
+ running_file=$(get_running_file "$app")
+
+ # Launch the app in background
+ "$real_binary" "$@" &
+ local app_pid=$!
+
+ # Give Electron apps time to fork before we start polling
+ sleep 2
+
+ # Record state (FORK-FREE: use printf %s for timestamp)
+ echo "$app_pid $(printf '%(%s)T' -1)" >"$running_file"
+ log_message "LAUNCHED: $app with PID $app_pid (auto-close in ${timeout_minutes}m)"
+
+ # Spawn the auto-close daemon in a completely detached subshell
+ # Uses process-name matching so it works for Electron apps that fork on launch
+ (
+ # Detach from terminal
+ exec /dev/null 2>&1
+
+ # Wait for warning time
+ sleep "$warning_seconds"
+
+ # Check if still running before warning (by process name, not PID)
+ if is_app_running "$proc_match"; then
+ # Send warning notification
+ notify-send -u critical -t 30000 "⏰ $app Closing Soon" \
+ "Session will end in ${AUTO_CLOSE_WARNING_MINUTES} minutes. Save your work!" 2>/dev/null || true
+ else
+ # Process already exited
+ rm -f "$running_file" 2>/dev/null || true
+ exit 0
+ fi
+
+ # Wait remaining time
+ sleep $((AUTO_CLOSE_WARNING_MINUTES * 60))
+
+ # Check if still running (by process name)
+ if is_app_running "$proc_match"; then
+ # Send final notification
+ notify-send -u critical -t 5000 "🚫 $app Session Ended" \
+ "Time's up! Closing $app now." 2>/dev/null || true
+
+ # Kill all matching processes (handles forked Electron children)
+ kill_app "$proc_match"
+
+ printf '%(%Y-%m-%d %H:%M:%S)T - AUTO-CLOSED: %s after %dm\n' -1 "$app" "${timeout_minutes}" >>"$LOG_FILE" 2>/dev/null || true
+ fi
+
+ rm -f "$running_file" 2>/dev/null || true
+ ) &
+ disown
+
+ # Wait for the app to exit by polling process name.
+ # Electron apps fork immediately so waiting on $app_pid would return too soon.
+ while is_app_running "$proc_match"; do
+ sleep 5
+ done
+
+ # Clean up running state
+ rm -f "$running_file" 2>/dev/null || true
+
+ log_message "EXITED: $app"
+}
+
+# Main wrapper function - called when wrapping app launches
+wrapper_main() {
+ local app="$1"
+ shift
+
+ ensure_state_dir
+
+ local real_binary
+ if ! real_binary=$(get_real_binary "$app"); then
+ log_message "ERROR: Real binary not found for $app"
+ echo "Error: Real binary for $app not found. Was the installer run?" >&2
+ exit 1
+ fi
+
+ # Clean up stale running state from previous crashes
+ cleanup_stale_running_state "$app"
+
+ if was_opened_this_hour "$app"; then
+ block_app "$app"
+ exit 1
+ fi
+
+ record_opening "$app"
+
+ # Launch with auto-close timer (replaces direct exec)
+ launch_with_timer "$app" "$real_binary" "$@"
+}