From a1e5eac6e0b47cadae6cb2419cd09fe78c67d57f Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 12 Aug 2026 12:17:20 -0700 Subject: [PATCH 01/10] feat: add lock-guard with process-list and mic-active suppression checks --- bin/lock-guard | 66 +++++++++++++++++++++++ tests/lock-guard.bats | 121 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100755 bin/lock-guard create mode 100755 tests/lock-guard.bats diff --git a/bin/lock-guard b/bin/lock-guard new file mode 100755 index 0000000..08d9ff2 --- /dev/null +++ b/bin/lock-guard @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +conf="${LOCK_SYNC_GUARD_PROCESSES:-$HOME/.config/lock-sync/guard-processes}" +pgrep_cmd="${LOCK_SYNC_PGREP:-/usr/bin/pgrep}" +ioreg_cmd="${LOCK_SYNC_IOREG:-/usr/sbin/ioreg}" +osascript_cmd="${LOCK_SYNC_OSASCRIPT:-/usr/bin/osascript}" +pmset_cmd="${LOCK_SYNC_PMSET:-/usr/bin/pmset}" + +# osascript_cmd reserved for Task 2 (Meet detection); reference to satisfy shellcheck +: "${osascript_cmd:?}" + +default_processes=("zoom.us" "Microsoft Teams" "FaceTime") + +log() { + local ts + ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + printf '%s %s\n' "$ts" "$*" +} + +guard_processes() { + if [[ -r "$conf" ]]; then + awk '/^[[:space:]]*(#|$)/ { next } { print }' "$conf" + else + printf '%s\n' "${default_processes[@]}" + fi +} + +# Prints the matching process name on stdout and returns 0 if any known +# meeting process is running; returns 1 (no output) otherwise. A pgrep +# failure for an individual name is treated as "not running", never fatal. +check_process() { + local name procs + procs=$(guard_processes) + while IFS= read -r name; do + [[ -n "$name" ]] || continue + if "$pgrep_cmd" -xq "$name" 2>/dev/null; then + printf '%s\n' "$name" + return 0 + fi + done <<<"$procs" + return 1 +} + +# Returns 0 if any audio engine reports a running input stream. ioreg +# failure/unexpected output is treated as "not active", never fatal. +check_mic_active() { + "$ioreg_cmd" -c IOAudioEngine -r -l 2>/dev/null \ + | grep -q '"IOAudioEngineState" = "running"' +} + +main() { + local match + if match="$(check_process)"; then + log "action=suppress reason=process:$match" + return 0 + fi + if check_mic_active; then + log "action=suppress reason=mic-active" + return 0 + fi + "$pmset_cmd" displaysleepnow >/dev/null 2>&1 || true + log "action=sleep" +} + +main "$@" diff --git a/tests/lock-guard.bats b/tests/lock-guard.bats new file mode 100755 index 0000000..a2ecf2e --- /dev/null +++ b/tests/lock-guard.bats @@ -0,0 +1,121 @@ +#!/usr/bin/env bats + +bats_require_minimum_version 1.5.0 + +# bats injects BATS_TEST_DIRNAME; pre-declare for shellcheck SC2154. +: "${BATS_TEST_DIRNAME:=}" + +setup() { + SCRIPT="$BATS_TEST_DIRNAME/../bin/lock-guard" + TMPDIR="$(mktemp -d)" + STUB_DIR="$TMPDIR/stub" + mkdir -p "$STUB_DIR" + + PMSET_LOG="$TMPDIR/pmset.log" + export PMSET_LOG + + export LOCK_SYNC_GUARD_PROCESSES="$TMPDIR/no-such-guard-processes" + export LOCK_SYNC_PGREP="$STUB_DIR/pgrep" + export LOCK_SYNC_IOREG="$STUB_DIR/ioreg" + export LOCK_SYNC_OSASCRIPT="$STUB_DIR/osascript" + export LOCK_SYNC_PMSET="$STUB_DIR/pmset" + + # Default stubs: nothing matches, nothing running, no tabs. Tests override + # individual stubs to trigger specific signals. + make_pgrep_stub_no_match + make_ioreg_stub_idle + make_osascript_stub_no_tabs + make_pmset_stub +} + +teardown() { + rm -rf "$TMPDIR" +} + +make_pgrep_stub_no_match() { + cat >"$STUB_DIR/pgrep" <<'STUBEOF' +#!/bin/bash +exit 1 +STUBEOF + chmod +x "$STUB_DIR/pgrep" +} + +# Matches only when the pattern passed to pgrep -x equals $1. +make_pgrep_stub_match() { + local want="$1" + cat >"$STUB_DIR/pgrep" <"$STUB_DIR/ioreg" <<'STUBEOF' +#!/bin/bash +cat <<'EOF' + "IOAudioEngineState" = 0 +EOF +STUBEOF + chmod +x "$STUB_DIR/ioreg" +} + +make_ioreg_stub_running() { + cat >"$STUB_DIR/ioreg" <<'STUBEOF' +#!/bin/bash +cat <<'EOF' + "IOAudioEngineState" = "running" +EOF +STUBEOF + chmod +x "$STUB_DIR/ioreg" +} + +make_osascript_stub_no_tabs() { + cat >"$STUB_DIR/osascript" <<'STUBEOF' +#!/bin/bash +exit 0 +STUBEOF + chmod +x "$STUB_DIR/osascript" +} + +make_pmset_stub() { + cat >"$STUB_DIR/pmset" <>"$PMSET_LOG" +exit 0 +STUBEOF + chmod +x "$STUB_DIR/pmset" +} + +@test "no signals present: calls pmset and logs action=sleep" { + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 1 ] + [[ "${lines[0]}" == *"action=sleep"* ]] + grep -q '^displaysleepnow$' "$PMSET_LOG" +} + +@test "known process running: suppresses and does not call pmset" { + make_pgrep_stub_match "zoom.us" + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 1 ] + [[ "${lines[0]}" == *"action=suppress"* ]] + [[ "${lines[0]}" == *"reason=process:zoom.us"* ]] + [ ! -s "$PMSET_LOG" ] +} + +@test "mic active: suppresses and does not call pmset" { + make_ioreg_stub_running + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 1 ] + [[ "${lines[0]}" == *"action=suppress"* ]] + [[ "${lines[0]}" == *"reason=mic-active"* ]] + [ ! -s "$PMSET_LOG" ] +} From 461e7b55ef74a02f0f4f888582532b01c218a5ce Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 12 Aug 2026 12:20:14 -0700 Subject: [PATCH 02/10] fix: remove unused osascript_cmd placeholder flagged by review --- bin/lock-guard | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bin/lock-guard b/bin/lock-guard index 08d9ff2..1f1d5a5 100755 --- a/bin/lock-guard +++ b/bin/lock-guard @@ -4,12 +4,8 @@ set -euo pipefail conf="${LOCK_SYNC_GUARD_PROCESSES:-$HOME/.config/lock-sync/guard-processes}" pgrep_cmd="${LOCK_SYNC_PGREP:-/usr/bin/pgrep}" ioreg_cmd="${LOCK_SYNC_IOREG:-/usr/sbin/ioreg}" -osascript_cmd="${LOCK_SYNC_OSASCRIPT:-/usr/bin/osascript}" pmset_cmd="${LOCK_SYNC_PMSET:-/usr/bin/pmset}" -# osascript_cmd reserved for Task 2 (Meet detection); reference to satisfy shellcheck -: "${osascript_cmd:?}" - default_processes=("zoom.us" "Microsoft Teams" "FaceTime") log() { From 8fae9c5aa06d234d1dcf1eeebaf514a29131d54c Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 12 Aug 2026 12:27:08 -0700 Subject: [PATCH 03/10] fix: harden Meet-tab detection per pre-commit review findings --- bin/lock-guard | 28 +++++++++++++++++++ tests/lock-guard.bats | 64 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/bin/lock-guard b/bin/lock-guard index 1f1d5a5..d35e892 100755 --- a/bin/lock-guard +++ b/bin/lock-guard @@ -4,6 +4,7 @@ set -euo pipefail conf="${LOCK_SYNC_GUARD_PROCESSES:-$HOME/.config/lock-sync/guard-processes}" pgrep_cmd="${LOCK_SYNC_PGREP:-/usr/bin/pgrep}" ioreg_cmd="${LOCK_SYNC_IOREG:-/usr/sbin/ioreg}" +osascript_cmd="${LOCK_SYNC_OSASCRIPT:-/usr/bin/osascript}" pmset_cmd="${LOCK_SYNC_PMSET:-/usr/bin/pmset}" default_processes=("zoom.us" "Microsoft Teams" "FaceTime") @@ -45,6 +46,29 @@ check_mic_active() { | grep -q '"IOAudioEngineState" = "running"' } +# Returns 0 if any open Chrome tab's URL matches an active Meet room +# (meet.google.com/xxx-yyyy-zzz), as opposed to the bare landing page. +# osascript failure (e.g. Automation permission not yet granted, or Chrome +# not running) is treated as "no match", never fatal — see design doc's +# "macOS Automation permission" section. +check_meet_tab_open() { + local urls + urls="$("$osascript_cmd" -e ' + with timeout of 3 seconds + tell application "Google Chrome" + set theURLs to {} + repeat with w in windows + repeat with t in tabs of w + set end of theURLs to URL of t + end repeat + end repeat + return theURLs + end tell + end timeout + ' 2>/dev/null)" || return 1 + [[ "$urls" =~ meet\.google\.com/[a-zA-Z0-9]{3}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{3} ]] +} + main() { local match if match="$(check_process)"; then @@ -55,6 +79,10 @@ main() { log "action=suppress reason=mic-active" return 0 fi + if check_meet_tab_open; then + log "action=suppress reason=meet-tab-open" + return 0 + fi "$pmset_cmd" displaysleepnow >/dev/null 2>&1 || true log "action=sleep" } diff --git a/tests/lock-guard.bats b/tests/lock-guard.bats index a2ecf2e..71e6e73 100755 --- a/tests/lock-guard.bats +++ b/tests/lock-guard.bats @@ -83,6 +83,14 @@ STUBEOF chmod +x "$STUB_DIR/osascript" } +make_osascript_stub_meet_tab() { + cat >"$STUB_DIR/osascript" <<'STUBEOF' +#!/bin/bash +echo "https://meet.google.com/abc-defg-hij" +STUBEOF + chmod +x "$STUB_DIR/osascript" +} + make_pmset_stub() { cat >"$STUB_DIR/pmset" <"$STUB_DIR/osascript" <<'STUBEOF' +#!/bin/bash +echo "https://meet.google.com/landing" +STUBEOF + chmod +x "$STUB_DIR/osascript" + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"action=sleep"* ]] + grep -q '^displaysleepnow$' "$PMSET_LOG" +} + +@test "osascript failure (e.g. missing Automation permission) does not suppress or crash" { + make_pgrep_stub_no_match + make_ioreg_stub_idle + cat >"$STUB_DIR/osascript" <<'STUBEOF' +#!/bin/bash +exit 1 +STUBEOF + chmod +x "$STUB_DIR/osascript" + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"action=sleep"* ]] + grep -q '^displaysleepnow$' "$PMSET_LOG" +} + +@test "Meet tab with mixed-case room code: suppresses and does not call pmset" { + make_pgrep_stub_no_match + make_ioreg_stub_idle + cat >"$STUB_DIR/osascript" <<'STUBEOF' +#!/bin/bash +echo "https://meet.google.com/ABC-defG-hij" +STUBEOF + chmod +x "$STUB_DIR/osascript" + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 1 ] + [[ "${lines[0]}" == *"action=suppress"* ]] + [[ "${lines[0]}" == *"reason=meet-tab-open"* ]] + [ ! -s "$PMSET_LOG" ] +} From b28f1822197051832f07f491f83b4d6a297d6541 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 12 Aug 2026 12:30:07 -0700 Subject: [PATCH 04/10] test: cover guard-processes config file override in lock-guard --- tests/lock-guard.bats | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/lock-guard.bats b/tests/lock-guard.bats index 71e6e73..301410e 100755 --- a/tests/lock-guard.bats +++ b/tests/lock-guard.bats @@ -183,3 +183,32 @@ STUBEOF [[ "${lines[0]}" == *"reason=meet-tab-open"* ]] [ ! -s "$PMSET_LOG" ] } + +@test "custom guard-processes config replaces the default list" { + cat >"$TMPDIR/guard-processes" <<'EOF' +# lock-sync guard-processes + +Slack Huddle + +# zoom.us intentionally not listed here +EOF + export LOCK_SYNC_GUARD_PROCESSES="$TMPDIR/guard-processes" + make_pgrep_stub_match "Slack Huddle" + + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"reason=process:Slack Huddle"* ]] +} + +@test "custom guard-processes config: zoom.us not matched when absent from custom list" { + cat >"$TMPDIR/guard-processes" <<'EOF' +Slack Huddle +EOF + export LOCK_SYNC_GUARD_PROCESSES="$TMPDIR/guard-processes" + make_pgrep_stub_match "zoom.us" + + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"action=sleep"* ]] + grep -q '^displaysleepnow$' "$PMSET_LOG" +} From 1b286a79845c4bdd323f1f26ce15ddad62f9b9c5 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 12 Aug 2026 12:32:27 -0700 Subject: [PATCH 05/10] feat: fanout invokes lock-guard on clients instead of bare pmset --- bin/lock-fanout | 2 +- tests/lock-fanout.bats | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/bin/lock-fanout b/bin/lock-fanout index 2029ab3..47e6193 100755 --- a/bin/lock-fanout +++ b/bin/lock-fanout @@ -35,7 +35,7 @@ fanout_host() { "$ssh_cmd" -n -o BatchMode=yes \ -o ConnectTimeout=5 \ -o StrictHostKeyChecking=accept-new \ - "$user@$host" pmset displaysleepnow >/dev/null 2>&1 || rc=$? + "$user@$host" lock-guard >/dev/null 2>&1 || rc=$? log "client=$host user=$user ssh_exit=$rc" } diff --git a/tests/lock-fanout.bats b/tests/lock-fanout.bats index 605beb5..283b9f1 100755 --- a/tests/lock-fanout.bats +++ b/tests/lock-fanout.bats @@ -39,22 +39,22 @@ make_list_clients_stub() { make_ssh_stub() { cat >"$STUB_DIR/ssh" <<'STUBEOF' #!/bin/bash -# Model real ssh's stdin behavior: by default it drains stdin (eating the -# caller's loop-reading here-string), but `-n` redirects stdin from /dev/null. -# Without this fidelity, the stub can't distinguish the pre-fix bug from the -# post-fix behavior. has_n=0 target="" +remote_cmd="" for a in "$@"; do case "$a" in -n) has_n=1 ;; + -o|BatchMode=yes|ConnectTimeout=5|StrictHostKeyChecking=accept-new) ;; *@*) target="$a" ;; + *) [[ -n "$target" ]] && remote_cmd="$a" ;; esac done if (( has_n == 0 )); then cat >/dev/null fi echo "TARGET=$target" >>"$SSH_LOG" +echo "REMOTE_CMD=$remote_cmd" >>"$SSH_LOG" if [[ -n "${STUB_SSH_FAIL_HOST:-}" && "$target" == *"$STUB_SSH_FAIL_HOST"* ]]; then exit 255 fi @@ -198,3 +198,12 @@ EOF [ -f "$TMPDIR/custom-ssh.marker" ] [ ! -s "$SSH_LOG" ] } + +@test "remote command is lock-guard, not a bare pmset call" { + make_list_clients_stub "asiago.local" + make_ssh_stub + + run "$SCRIPT" + [ "$status" -eq 0 ] + grep -q '^REMOTE_CMD=lock-guard$' "$SSH_LOG" +} From 6d893704f493148d2ebe94bf0678b6c8f0acf3a9 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 12 Aug 2026 12:34:41 -0700 Subject: [PATCH 06/10] feat: install/uninstall lock-guard alongside the other lock-sync binaries --- bin/install | 2 +- bin/uninstall | 2 +- tests/install.bats | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/install b/bin/install index a6f173e..72813c0 100755 --- a/bin/install +++ b/bin/install @@ -14,7 +14,7 @@ say() { printf 'install: %s\n' "$*"; } mkdir -p "$bin_dir" "$plist_dir" "$(dirname "$log_path")" -for b in lock-watcher lock-fanout list-clients; do +for b in lock-watcher lock-fanout list-clients lock-guard; do ln -sf "$repo_root/bin/$b" "$bin_dir/$b" say "symlinked $bin_dir/$b -> $repo_root/bin/$b" done diff --git a/bin/uninstall b/bin/uninstall index ac3f08e..66fa31a 100755 --- a/bin/uninstall +++ b/bin/uninstall @@ -15,7 +15,7 @@ if [[ -f "$plist_path" ]]; then say "removed $plist_path" fi -for b in lock-watcher lock-fanout list-clients; do +for b in lock-watcher lock-fanout list-clients lock-guard; do if [[ -L "$bin_dir/$b" ]]; then rm "$bin_dir/$b" say "removed $bin_dir/$b" diff --git a/tests/install.bats b/tests/install.bats index da30e44..28e2716 100755 --- a/tests/install.bats +++ b/tests/install.bats @@ -47,6 +47,7 @@ teardown() { [ -L "$bin_dir/lock-watcher" ] [ -L "$bin_dir/lock-fanout" ] [ -L "$bin_dir/list-clients" ] + [ -L "$bin_dir/lock-guard" ] [ -f "$plist_path" ] [ -d "$(dirname "$log_path")" ] @@ -97,6 +98,7 @@ teardown() { [ ! -L "$bin_dir/lock-watcher" ] [ ! -L "$bin_dir/lock-fanout" ] [ ! -L "$bin_dir/list-clients" ] + [ ! -L "$bin_dir/lock-guard" ] grep -q "^bootout gui/$(id -u)/$LABEL\$" "$LAUNCHCTL_LOG" } From f0759d2e2ce45202383aaa53be84819fc92e0806 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 12 Aug 2026 12:37:30 -0700 Subject: [PATCH 07/10] docs: document lock-guard, guard-processes config, and Chrome Automation setup --- CLAUDE.md | 15 +++++++++++++++ README.md | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8d5ece5..af639a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ SSH-to-self is a no-op on an already-locked Mac — do not special-case it. - **Synergy client list:** `~/Library/Preferences/Synergy/synergy.conf`. This is the source of truth for which hosts to lock — do not maintain a parallel client list. - **Synergy display names:** `~/Library/Preferences/Synergy/db.json` (sibling of the conf; overridable via `LOCK_SYNC_DB`). Synergy sanitizes screen names before writing the conf (e.g. it drops hyphens: `arich-mac` becomes the screen `arichmac-3181d4b4`), so the conf alone cannot reproduce a client's real hostname. `list-clients` recovers the true name from db.json's `computers[].name`, joined on the last 8 hex of `id` matching the conf's instance suffix. Read via `jq` (a soft dependency — macOS ships `/usr/bin/jq`; Homebrew paths are probed as a fallback). When jq or db.json is unavailable, it degrades to stripping the suffix. Use `name`, not `hostname` — the two diverge (`hostname` can be the default `Andrews-Mac-mini.local`). - **Per-client username overrides:** `~/.config/lock-sync/config` (overridable via `LOCK_SYNC_CONFIG`). Plain text, whitespace-separated ` `, `#` line comments. Unlisted hosts use `$USER`. Absent or empty file is a valid state. +- **Meeting-app suppression list:** `~/.config/lock-sync/guard-processes` (overridable via `LOCK_SYNC_GUARD_PROCESSES`). Same shape as the username-override config: plain text, one process name per line, `#` comments. Absent file uses the built-in default list (`zoom.us`, `Microsoft Teams`, `FaceTime`). Present file replaces the default list entirely (not merged). ## Runtime @@ -40,6 +41,19 @@ Bash, shellcheck-clean (project follows the user's global bash standards in `~/. All slices shipped. Install with `bin/install`; uninstall with `bin/uninstall`. The LaunchAgent auto-starts the watcher on login and writes its log to `~/Library/Logs/lock-sync.log`. Per-slice design docs live under `docs/plans/`. +## lock-guard: one-time Chrome Automation permission + +`lock-guard`'s Google Meet detection drives Chrome via `osascript`. The +first time this runs on a client, macOS prompts for Automation permission +(System Settings > Privacy & Security > Automation). Because `lock-guard` +runs non-interactively over SSH from launchd, there is no interactive +session to click "Allow" in — grant this manually once per client, by +running `bin/lock-guard` interactively at a local Terminal on that client +after install, approving the Chrome automation prompt when it appears. If +the permission is never granted, Meet-tab detection silently fails open +(treated as "no Meet tab found," never as a fatal error) — the process-list +and mic-active checks still work normally. + ## Commands - `bats tests/` — run the test suite. Local: `brew install bats-core`. CI: installs bats-core v1.10.0 from source to `$HOME/.local` (see `.github/workflows/ci.yml`). @@ -49,6 +63,7 @@ All slices shipped. Install with `bin/install`; uninstall with `bin/uninstall`. - `bin/list-clients [path]` — slice (a). Parse Synergy conf, emit `.local` hostnames, resolving real display names via the sibling `db.json` when available (see Key external inputs). No arg → reads `~/Library/Preferences/Synergy/synergy.conf`. - `bin/lock-watcher` — slices (b)+(c1). Subscribe to `com.apple.sessionagent.screenIsLocked`; on each event emit ` locked` and invoke `bin/lock-fanout`. Blocks until signaled. - `bin/lock-fanout` — slice (c1). Reads hosts from `list-clients`, applies per-host overrides from `~/.config/lock-sync/config`, SSHes `pmset displaysleepnow` per host, emits one ` client= user= ssh_exit=` line per host. +- `bin/lock-guard` — runs on each client (invoked remotely by `lock-fanout` in place of a bare `pmset displaysleepnow`). Skips the lock and logs why if the client looks like it's in a call: a known meeting app is running (`~/.config/lock-sync/guard-processes`), the microphone is actively in use, or a Google Meet room tab is open in Chrome. Emits ` action=sleep` or ` action=suppress reason=`. ## `.claude/` directory diff --git a/README.md b/README.md index b022f4b..ae23215 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,40 @@ mimolette.local admin Unlisted hosts fall back to `$USER`. Absent or empty file is a valid state. +## Configure meeting-app suppression (optional) + +By default, `lock-guard` (running on each client) suppresses the lock if it +detects `zoom.us`, `Microsoft Teams`, or `FaceTime` running. To customize +this list, create `~/.config/lock-sync/guard-processes` (overridable via +`LOCK_SYNC_GUARD_PROCESSES`): + +```text +# ~/.config/lock-sync/guard-processes +# one process name per line, `#` starts a line comment +zoom.us +Microsoft Teams +FaceTime +Slack +``` + +Absent file uses the built-in default. Present file replaces it entirely +(not merged). + +## Chrome Automation permission (one-time setup) + +`lock-guard`'s Google Meet detection requires macOS Automation permission. +On first install, run `bin/lock-guard` interactively at a Terminal on each +client to grant this permission: + +```sh +bin/lock-guard +``` + +When prompted, navigate to System Settings > Privacy & Security > Automation +and allow Terminal (or your shell's name) to automate Chrome. Without this +permission, Meet-tab detection silently skips (the process-list and +microphone checks still work). + ## Verify After install, lock the screen (Ctrl+Cmd+Q) and tail the log: @@ -135,7 +169,8 @@ bin/ ├── uninstall # reverse install, preserve log ├── list-clients # parse synergy.conf → .local hostnames ├── lock-watcher # subscribe to Darwin screen-lock notification -└── lock-fanout # ssh pmset displaysleepnow to each client +├── lock-fanout # ssh pmset displaysleepnow to each client +└── lock-guard # client-side lock suppression (meeting detection) tests/ # bats test suite (`bats tests/` to run) docs/plans/ # per-slice design docs From 1ea7e6b8b99dbee60adddde2ba045024e71bcc98 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 12 Aug 2026 12:39:08 -0700 Subject: [PATCH 08/10] docs: fix stale lock-fanout description after lock-guard wiring --- CLAUDE.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index af639a9..2bffacd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ and mic-active checks still work normally. - `bin/uninstall` — bootout the agent, remove plist and symlinks. Preserves the log file. Idempotent. - `bin/list-clients [path]` — slice (a). Parse Synergy conf, emit `.local` hostnames, resolving real display names via the sibling `db.json` when available (see Key external inputs). No arg → reads `~/Library/Preferences/Synergy/synergy.conf`. - `bin/lock-watcher` — slices (b)+(c1). Subscribe to `com.apple.sessionagent.screenIsLocked`; on each event emit ` locked` and invoke `bin/lock-fanout`. Blocks until signaled. -- `bin/lock-fanout` — slice (c1). Reads hosts from `list-clients`, applies per-host overrides from `~/.config/lock-sync/config`, SSHes `pmset displaysleepnow` per host, emits one ` client= user= ssh_exit=` line per host. +- `bin/lock-fanout` — slice (c1). Reads hosts from `list-clients`, applies per-host overrides from `~/.config/lock-sync/config`, SSHes `lock-guard` to each client (see the `bin/lock-guard` bullet below), emits one ` client= user= ssh_exit=` line per host. - `bin/lock-guard` — runs on each client (invoked remotely by `lock-fanout` in place of a bare `pmset displaysleepnow`). Skips the lock and logs why if the client looks like it's in a call: a known meeting app is running (`~/.config/lock-sync/guard-processes`), the microphone is actively in use, or a Google Meet room tab is open in Chrome. Emits ` action=sleep` or ` action=suppress reason=`. ## `.claude/` directory diff --git a/README.md b/README.md index ae23215..e305972 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ bin/ ├── uninstall # reverse install, preserve log ├── list-clients # parse synergy.conf → .local hostnames ├── lock-watcher # subscribe to Darwin screen-lock notification -├── lock-fanout # ssh pmset displaysleepnow to each client +├── lock-fanout # ssh lock-guard to each client └── lock-guard # client-side lock suppression (meeting detection) tests/ # bats test suite (`bats tests/` to run) From d2fd7c926eda804b730f13d7e554b651414e9ca5 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 12 Aug 2026 12:50:15 -0700 Subject: [PATCH 09/10] fix: address final review findings (SSH PATH, Apple Silicon mic detection, Meet URL anchoring, config whitespace) - lock-fanout: invoke lock-guard via $HOME/.local/bin/lock-guard (escaped for remote expansion) instead of a bare name, since ~/.local/bin is not on the non-interactive SSH PATH. - lock-guard: replace ioreg-based mic detection (dead code on Apple Silicon) with pmset -g assertions / coreaudiod PreventUserIdleSleep. - lock-guard: anchor the Meet URL regex to https://meet.google.com/ to avoid matching unrelated hosts/substrings. - lock-guard: trim trailing whitespace/CRLF in guard-processes config lines so they match pgrep -x correctly. - CLAUDE.md: document that upgrading requires re-running bin/install on every Synergy client, not just the primary machine. --- CLAUDE.md | 2 + bin/lock-fanout | 6 ++- bin/lock-guard | 19 +++++--- tests/lock-fanout.bats | 4 +- tests/lock-guard.bats | 104 ++++++++++++++++++++++++++++++----------- 5 files changed, 98 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2bffacd..a906a7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,8 @@ Bash, shellcheck-clean (project follows the user's global bash standards in `~/. All slices shipped. Install with `bin/install`; uninstall with `bin/uninstall`. The LaunchAgent auto-starts the watcher on login and writes its log to `~/Library/Logs/lock-sync.log`. Per-slice design docs live under `docs/plans/`. +**Upgrade path:** every machine acting as a Synergy client is a symmetric peer, not just the primary/server — `bin/install` must be re-run on EVERY client machine when upgrading, not only on the machine you're actively developing on. `lock-fanout` invokes `lock-guard` on each client via the absolute path `$HOME/.local/bin/lock-guard`, which only exists once `bin/install` has symlinked it there. Skipping install on any client leaves `lock-guard` missing on that client, and locking fails for it (ssh_exit=127 / command-not-found) until install is re-run there. + ## lock-guard: one-time Chrome Automation permission `lock-guard`'s Google Meet detection drives Chrome via `osascript`. The diff --git a/bin/lock-fanout b/bin/lock-fanout index 47e6193..3146519 100755 --- a/bin/lock-fanout +++ b/bin/lock-fanout @@ -32,10 +32,14 @@ fanout_host() { # reads from the caller's stdin, and in `main`'s `while read <<< "$clients"` # loop that means ssh consumes the remaining hosts, so only the first # iteration runs. + # Absolute path, with $HOME escaped so it expands on the remote shell, not + # locally: a non-interactive ssh session's PATH (/usr/bin:/bin:/usr/sbin: + # /sbin) does not include ~/.local/bin, where bin/install symlinks + # lock-guard. "$ssh_cmd" -n -o BatchMode=yes \ -o ConnectTimeout=5 \ -o StrictHostKeyChecking=accept-new \ - "$user@$host" lock-guard >/dev/null 2>&1 || rc=$? + "$user@$host" "\$HOME/.local/bin/lock-guard" >/dev/null 2>&1 || rc=$? log "client=$host user=$user ssh_exit=$rc" } diff --git a/bin/lock-guard b/bin/lock-guard index d35e892..6e8c4d3 100755 --- a/bin/lock-guard +++ b/bin/lock-guard @@ -3,7 +3,6 @@ set -euo pipefail conf="${LOCK_SYNC_GUARD_PROCESSES:-$HOME/.config/lock-sync/guard-processes}" pgrep_cmd="${LOCK_SYNC_PGREP:-/usr/bin/pgrep}" -ioreg_cmd="${LOCK_SYNC_IOREG:-/usr/sbin/ioreg}" osascript_cmd="${LOCK_SYNC_OSASCRIPT:-/usr/bin/osascript}" pmset_cmd="${LOCK_SYNC_PMSET:-/usr/bin/pmset}" @@ -17,7 +16,7 @@ log() { guard_processes() { if [[ -r "$conf" ]]; then - awk '/^[[:space:]]*(#|$)/ { next } { print }' "$conf" + awk '/^[[:space:]]*(#|$)/ { next } { gsub(/[[:space:]]+$|\r$/, ""); if ($0 != "") print }' "$conf" else printf '%s\n' "${default_processes[@]}" fi @@ -28,6 +27,8 @@ guard_processes() { # failure for an individual name is treated as "not running", never fatal. check_process() { local name procs + # Captured to a variable (not process-substituted) because this function's + # own stdout is captured by the caller via $(check_process). procs=$(guard_processes) while IFS= read -r name; do [[ -n "$name" ]] || continue @@ -39,11 +40,15 @@ check_process() { return 1 } -# Returns 0 if any audio engine reports a running input stream. ioreg -# failure/unexpected output is treated as "not active", never fatal. +# Returns 0 if coreaudiod currently holds a PreventUserIdleSystemSleep power +# assertion, which it takes for the exact duration any process has an active +# audio input stream open. Verified against real mic open/close on Apple +# Silicon; IOAudioEngine (the previous approach) is a legacy Intel/kext-era +# IOKit class with zero live instances under the modern Core Audio HAL. +# pmset failure/unexpected output is treated as "not active", never fatal. check_mic_active() { - "$ioreg_cmd" -c IOAudioEngine -r -l 2>/dev/null \ - | grep -q '"IOAudioEngineState" = "running"' + "$pmset_cmd" -g assertions 2>/dev/null \ + | grep -qi 'coreaudiod.*preventuseridlesleep' } # Returns 0 if any open Chrome tab's URL matches an active Meet room @@ -66,7 +71,7 @@ check_meet_tab_open() { end tell end timeout ' 2>/dev/null)" || return 1 - [[ "$urls" =~ meet\.google\.com/[a-zA-Z0-9]{3}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{3} ]] + [[ "$urls" =~ https://meet\.google\.com/[a-zA-Z0-9]{3}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{3} ]] } main() { diff --git a/tests/lock-fanout.bats b/tests/lock-fanout.bats index 283b9f1..1a5e96e 100755 --- a/tests/lock-fanout.bats +++ b/tests/lock-fanout.bats @@ -199,11 +199,11 @@ EOF [ ! -s "$SSH_LOG" ] } -@test "remote command is lock-guard, not a bare pmset call" { +@test "remote command is lock-guard's absolute path, not a bare pmset call" { make_list_clients_stub "asiago.local" make_ssh_stub run "$SCRIPT" [ "$status" -eq 0 ] - grep -q '^REMOTE_CMD=lock-guard$' "$SSH_LOG" + grep -q '^REMOTE_CMD=\$HOME/.local/bin/lock-guard$' "$SSH_LOG" } diff --git a/tests/lock-guard.bats b/tests/lock-guard.bats index 301410e..98d1a6d 100755 --- a/tests/lock-guard.bats +++ b/tests/lock-guard.bats @@ -16,14 +16,12 @@ setup() { export LOCK_SYNC_GUARD_PROCESSES="$TMPDIR/no-such-guard-processes" export LOCK_SYNC_PGREP="$STUB_DIR/pgrep" - export LOCK_SYNC_IOREG="$STUB_DIR/ioreg" export LOCK_SYNC_OSASCRIPT="$STUB_DIR/osascript" export LOCK_SYNC_PMSET="$STUB_DIR/pmset" # Default stubs: nothing matches, nothing running, no tabs. Tests override # individual stubs to trigger specific signals. make_pgrep_stub_no_match - make_ioreg_stub_idle make_osascript_stub_no_tabs make_pmset_stub } @@ -55,26 +53,6 @@ STUBEOF chmod +x "$STUB_DIR/pgrep" } -make_ioreg_stub_idle() { - cat >"$STUB_DIR/ioreg" <<'STUBEOF' -#!/bin/bash -cat <<'EOF' - "IOAudioEngineState" = 0 -EOF -STUBEOF - chmod +x "$STUB_DIR/ioreg" -} - -make_ioreg_stub_running() { - cat >"$STUB_DIR/ioreg" <<'STUBEOF' -#!/bin/bash -cat <<'EOF' - "IOAudioEngineState" = "running" -EOF -STUBEOF - chmod +x "$STUB_DIR/ioreg" -} - make_osascript_stub_no_tabs() { cat >"$STUB_DIR/osascript" <<'STUBEOF' #!/bin/bash @@ -91,9 +69,41 @@ STUBEOF chmod +x "$STUB_DIR/osascript" } +# Combined pmset stub, branching on its arguments: when called with +# `-g assertions` (the mic-active check), prints nothing — idle mic. When +# called with anything else (i.e. `displaysleepnow`), logs the args to +# PMSET_LOG as before, so existing displaysleepnow assertions keep working. make_pmset_stub() { cat >"$STUB_DIR/pmset" <>"$PMSET_LOG" +exit 0 +STUBEOF + chmod +x "$STUB_DIR/pmset" +} + +# Same idle behavior as make_pmset_stub; kept as a distinct, clearly-named +# entry point for tests that want to be explicit about the mic state. +make_pmset_stub_mic_idle() { + make_pmset_stub +} + +# Same branching pmset stub, but `-g assertions` prints a real captured line +# from a live mic-open state on Apple Silicon (macOS 26.6.1) — per the +# review's lesson not to hand-write stub output that merely echoes what the +# implementation expects. Still logs displaysleepnow calls to PMSET_LOG. +make_pmset_stub_mic_active() { + cat >"$STUB_DIR/pmset" <>"$PMSET_LOG" exit 0 STUBEOF @@ -119,7 +129,7 @@ STUBEOF } @test "mic active: suppresses and does not call pmset" { - make_ioreg_stub_running + make_pmset_stub_mic_active run "$SCRIPT" [ "$status" -eq 0 ] [ "${#lines[@]}" -eq 1 ] @@ -130,7 +140,7 @@ STUBEOF @test "Meet tab open in Chrome: suppresses and does not call pmset" { make_pgrep_stub_no_match - make_ioreg_stub_idle + make_pmset_stub_mic_idle make_osascript_stub_meet_tab run "$SCRIPT" [ "$status" -eq 0 ] @@ -142,7 +152,7 @@ STUBEOF @test "Meet landing page open (no room code) does not suppress" { make_pgrep_stub_no_match - make_ioreg_stub_idle + make_pmset_stub_mic_idle cat >"$STUB_DIR/osascript" <<'STUBEOF' #!/bin/bash echo "https://meet.google.com/landing" @@ -156,7 +166,7 @@ STUBEOF @test "osascript failure (e.g. missing Automation permission) does not suppress or crash" { make_pgrep_stub_no_match - make_ioreg_stub_idle + make_pmset_stub_mic_idle cat >"$STUB_DIR/osascript" <<'STUBEOF' #!/bin/bash exit 1 @@ -170,7 +180,7 @@ STUBEOF @test "Meet tab with mixed-case room code: suppresses and does not call pmset" { make_pgrep_stub_no_match - make_ioreg_stub_idle + make_pmset_stub_mic_idle cat >"$STUB_DIR/osascript" <<'STUBEOF' #!/bin/bash echo "https://meet.google.com/ABC-defG-hij" @@ -184,6 +194,34 @@ STUBEOF [ ! -s "$PMSET_LOG" ] } +@test "URL with meet.google.com as a substring but wrong host does not suppress" { + make_pgrep_stub_no_match + make_pmset_stub_mic_idle + cat >"$STUB_DIR/osascript" <<'STUBEOF' +#!/bin/bash +echo "https://evil.com/?r=meet.google.com/abc-defg-hij" +STUBEOF + chmod +x "$STUB_DIR/osascript" + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"action=sleep"* ]] + grep -q '^displaysleepnow$' "$PMSET_LOG" +} + +@test "URL with notmeet.google.com host does not suppress" { + make_pgrep_stub_no_match + make_pmset_stub_mic_idle + cat >"$STUB_DIR/osascript" <<'STUBEOF' +#!/bin/bash +echo "https://notmeet.google.com/abc-defg-hij" +STUBEOF + chmod +x "$STUB_DIR/osascript" + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"action=sleep"* ]] + grep -q '^displaysleepnow$' "$PMSET_LOG" +} + @test "custom guard-processes config replaces the default list" { cat >"$TMPDIR/guard-processes" <<'EOF' # lock-sync guard-processes @@ -212,3 +250,15 @@ EOF [[ "${lines[0]}" == *"action=sleep"* ]] grep -q '^displaysleepnow$' "$PMSET_LOG" } + +@test "guard-processes config line with trailing whitespace still matches" { + printf 'Slack Huddle \n' >"$TMPDIR/guard-processes" + export LOCK_SYNC_GUARD_PROCESSES="$TMPDIR/guard-processes" + make_pgrep_stub_match "Slack Huddle" + + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"action=suppress"* ]] + [[ "${lines[0]}" == *"reason=process:Slack Huddle"* ]] + [ ! -s "$PMSET_LOG" ] +} From cf92410669cd9f9895d21d27bce4ce14423ef157 Mon Sep 17 00:00:00 2001 From: Claude Code Bot Date: Wed, 12 Aug 2026 12:52:26 -0700 Subject: [PATCH 10/10] docs: add design plan for lock-guard meeting suppression Claude-Session: https://claude.ai/code/session_01Sasr2N9Rj9n8rv2sjwN2YJ --- ...2-lock-guard-meeting-suppression-design.md | 815 ++++++++++++++++++ 1 file changed, 815 insertions(+) create mode 100644 docs/plans/2026-08-12-lock-guard-meeting-suppression-design.md diff --git a/docs/plans/2026-08-12-lock-guard-meeting-suppression-design.md b/docs/plans/2026-08-12-lock-guard-meeting-suppression-design.md new file mode 100644 index 0000000..e3a674e --- /dev/null +++ b/docs/plans/2026-08-12-lock-guard-meeting-suppression-design.md @@ -0,0 +1,815 @@ +# `lock-guard`: suppress lock during meetings + +Date: 2026-08-12 +Status: approved, ready to implement + +## Purpose + +When the user is on a call on `arich-mac` (a Synergy client), the Synergy +*server* machine going to its own idle-timeout screensaver and locking fires +`lock-fanout`, which SSHes `pmset displaysleepnow` to `arich-mac` — sleeping +its display mid-meeting. This is a client-side, self-contained fix: the +client refuses to sleep its own display when it detects it's in a call. + +## Non-goals + +No change to the project's stated non-goals (no unlock, no bidirectional +sync, no remote wake). The client does not signal the server. The server's +behavior (fanout to all clients) is unchanged. Suppression is a purely local +decision made by each client about its own lock command. + +## Design + +Today `lock-fanout` runs `pmset displaysleepnow` directly on each client via +SSH. This plan inserts a new script, `bin/lock-guard`, between them: SSH +invokes `lock-guard` instead, and `lock-guard` decides whether to actually +call `pmset displaysleepnow` or skip it and log why. + +`lock-guard` suppresses (skips the sleep) if **any** of these are true: + +1. **Known meeting app running** — `pgrep -q` matches a line in + `~/.config/lock-sync/guard-processes` (or a built-in default list if that + file doesn't exist): `zoom.us`, `Microsoft Teams`, `FaceTime`. +2. **Microphone actively in use** — `ioreg -c IOAudioEngine -r -l` shows an + audio engine in `running` state. Catches apps not in the process list, + and any call where you're actually speaking. +3. **Google Meet tab open in Chrome** — `osascript` asks Chrome for open tab + URLs and checks for the Meet in-call URL pattern + (`meet.google.com/xxx-yyyy-zzz`, not the bare landing page). This is the + one signal that catches a *muted* all-hands, where the mic check (#2) + would otherwise miss it. + +Each signal is checked independently; the first match wins and is logged by +name so the log line says *why* a lock was skipped +(`reason=process:zoom.us`, `reason=mic-active`, `reason=meet-tab-open`). If +none match, `lock-guard` calls `pmset displaysleepnow` as before. + +### Why not just check `osascript`/Chrome for everything + +Zoom/Teams/FaceTime are native apps with stable process names — `pgrep` is +cheaper and more reliable than driving them via Accessibility/AppleEvents, +which they don't reliably support anyway. Chrome tab enumeration is reserved +for the one case (Meet) that has no process-level signal. + +### macOS Automation permission + +The first time `lock-guard` runs the `osascript`/Chrome step, macOS will +prompt for one-time Automation permission (System Settings > Privacy & +Security > Automation > `lock-guard`'s parent process (ssh via launchd) > +Google Chrome). Since `lock-guard` runs non-interactively over SSH from +launchd, there's no user session to click "Allow" in reliably — document this +as a manual one-time setup step per client machine in the script's own +comments and in this doc. If the permission is not granted, `osascript` exits +non-zero / returns empty; treat that the same as "no Meet tab found" (fail +open on this signal only — do not let a missing permission block the whole +guard). + +### Failure mode philosophy + +`lock-guard` must never let a broken signal-check prevent locking +indefinitely, and must never crash in a way that leaves the display awake +with no explanation. Every individual check is independently guarded: a +non-zero/errored `pgrep`, `ioreg`, or `osascript` counts as "signal not +present" (does not suppress), not as a fatal error. Only an explicit match +suppresses. This mirrors `lock-fanout`'s existing philosophy of "degrade to +inaction, but always log why." + +## Files touched + +- **Create:** `bin/lock-guard` — the new client-side script. +- **Modify:** `bin/lock-fanout` — remote command changes from + `pmset displaysleepnow` to `lock-guard`. +- **Modify:** `bin/install` — add `lock-guard` to the list of symlinked + binaries (every machine is symmetrically a potential client, so every + machine needs `lock-guard` on its `PATH`). +- **Modify:** `bin/uninstall` — no logic change needed since it already + iterates the same binary list as `install` (verify during implementation; + update if uninstall hardcodes a separate list). +- **Create:** `tests/lock-guard.bats` — new test file. +- **Modify:** `tests/lock-fanout.bats` — update the ssh stub assertion: the + remote command is now `lock-guard`, not `pmset displaysleepnow`. +- **Modify:** `tests/install.bats` — assert `lock-guard` is symlinked too. +- **Modify:** `README.md` / `CLAUDE.md` — document the new script, its + config file, and the one-time Automation permission step. + +## Testability + +`pgrep`, `ioreg`, and `osascript` cannot be run for real inside `bats` (no +real meeting apps, no real mic hardware state, no real Chrome automation +permission in CI). Following the existing pattern in this repo (SSH is +stubbed via `LOCK_SYNC_SSH`, `list-clients` is stubbed via +`LOCK_SYNC_LIST_CLIENTS`), `lock-guard` takes the same approach: each of the +three external commands is invoked through an overridable variable +(`LOCK_SYNC_PGREP`, `LOCK_SYNC_IOREG`, `LOCK_SYNC_OSASCRIPT`), each +defaulting to the real absolute-path binary. Tests point these at stub +scripts under a `STUB_DIR`, matching `lock-fanout.bats`'s `make_ssh_stub` +pattern. `pmset` itself is also stubbed the same way +(`LOCK_SYNC_PMSET`), so tests can assert whether it was called or not. + +## Config file: `~/.config/lock-sync/guard-processes` + +Same shape as the existing `~/.config/lock-sync/config` (plain text, +one-per-line, `#` comments, blank lines ignored). One process name per line. +Absent file → built-in default list (`zoom.us`, `Microsoft Teams`, +`FaceTime`). Present file → completely replaces the default list (not +merged — consistent with "you're editing this because the defaults don't +fit you"). + +--- + +## Task 1: `lock-guard` core — process-list and mic-active signals, `pmset` gate + +**Files:** + +- Create: `bin/lock-guard` +- Test: `tests/lock-guard.bats` + +**Interfaces:** + +- Produces: `bin/lock-guard` executable script, no arguments. Reads + `LOCK_SYNC_GUARD_PROCESSES` (config file path override, defaults to + `~/.config/lock-sync/guard-processes`), `LOCK_SYNC_PGREP` (defaults to + `/usr/bin/pgrep`), `LOCK_SYNC_IOREG` (defaults to `/usr/sbin/ioreg`), + `LOCK_SYNC_PMSET` (defaults to `/usr/bin/pmset`). Exits 0 always (mirrors + `lock-fanout`'s "never let this script's own exit code break the caller's + loop" philosophy — the interesting signal is the log line, not the exit + code). Emits one line to stdout: either + ` action=sleep` (mic/process/meet checks all clear, pmset + called) or ` action=suppress reason=` (one of the + checks matched, pmset not called). + +- [ ] **Step 1: Write the failing tests for process-list and mic-active suppression** + +Create `tests/lock-guard.bats`: + +```bash +#!/usr/bin/env bats + +bats_require_minimum_version 1.5.0 + +# bats injects BATS_TEST_DIRNAME; pre-declare for shellcheck SC2154. +: "${BATS_TEST_DIRNAME:=}" + +setup() { + SCRIPT="$BATS_TEST_DIRNAME/../bin/lock-guard" + TMPDIR="$(mktemp -d)" + STUB_DIR="$TMPDIR/stub" + mkdir -p "$STUB_DIR" + + PMSET_LOG="$TMPDIR/pmset.log" + export PMSET_LOG + + export LOCK_SYNC_GUARD_PROCESSES="$TMPDIR/no-such-guard-processes" + export LOCK_SYNC_PGREP="$STUB_DIR/pgrep" + export LOCK_SYNC_IOREG="$STUB_DIR/ioreg" + export LOCK_SYNC_OSASCRIPT="$STUB_DIR/osascript" + export LOCK_SYNC_PMSET="$STUB_DIR/pmset" + + # Default stubs: nothing matches, nothing running, no tabs. Tests override + # individual stubs to trigger specific signals. + make_pgrep_stub_no_match + make_ioreg_stub_idle + make_osascript_stub_no_tabs + make_pmset_stub +} + +teardown() { + rm -rf "$TMPDIR" +} + +make_pgrep_stub_no_match() { + cat >"$STUB_DIR/pgrep" <<'STUBEOF' +#!/bin/bash +exit 1 +STUBEOF + chmod +x "$STUB_DIR/pgrep" +} + +# Matches only when the pattern passed to pgrep -x equals $1. +make_pgrep_stub_match() { + local want="$1" + cat >"$STUB_DIR/pgrep" <"$STUB_DIR/ioreg" <<'STUBEOF' +#!/bin/bash +cat <<'EOF' + "IOAudioEngineState" = 0 +EOF +STUBEOF + chmod +x "$STUB_DIR/ioreg" +} + +make_ioreg_stub_running() { + cat >"$STUB_DIR/ioreg" <<'STUBEOF' +#!/bin/bash +cat <<'EOF' + "IOAudioEngineState" = "running" +EOF +STUBEOF + chmod +x "$STUB_DIR/ioreg" +} + +make_osascript_stub_no_tabs() { + cat >"$STUB_DIR/osascript" <<'STUBEOF' +#!/bin/bash +exit 0 +STUBEOF + chmod +x "$STUB_DIR/osascript" +} + +make_pmset_stub() { + cat >"$STUB_DIR/pmset" <>"$PMSET_LOG" +exit 0 +STUBEOF + chmod +x "$STUB_DIR/pmset" +} + +@test "no signals present: calls pmset and logs action=sleep" { + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 1 ] + [[ "${lines[0]}" == *"action=sleep"* ]] + grep -q '^displaysleepnow$' "$PMSET_LOG" +} + +@test "known process running: suppresses and does not call pmset" { + make_pgrep_stub_match "zoom.us" + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 1 ] + [[ "${lines[0]}" == *"action=suppress"* ]] + [[ "${lines[0]}" == *"reason=process:zoom.us"* ]] + [ ! -s "$PMSET_LOG" ] +} + +@test "mic active: suppresses and does not call pmset" { + make_ioreg_stub_running + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 1 ] + [[ "${lines[0]}" == *"action=suppress"* ]] + [[ "${lines[0]}" == *"reason=mic-active"* ]] + [ ! -s "$PMSET_LOG" ] +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bats tests/lock-guard.bats` +Expected: FAIL — `bin/lock-guard` does not exist yet. + +- [ ] **Step 3: Write `bin/lock-guard` (process-list and mic-active checks only; Meet check added in Task 2)** + +```bash +#!/usr/bin/env bash +set -euo pipefail + +conf="${LOCK_SYNC_GUARD_PROCESSES:-$HOME/.config/lock-sync/guard-processes}" +pgrep_cmd="${LOCK_SYNC_PGREP:-/usr/bin/pgrep}" +ioreg_cmd="${LOCK_SYNC_IOREG:-/usr/sbin/ioreg}" +osascript_cmd="${LOCK_SYNC_OSASCRIPT:-/usr/bin/osascript}" +pmset_cmd="${LOCK_SYNC_PMSET:-/usr/bin/pmset}" + +default_processes=("zoom.us" "Microsoft Teams" "FaceTime") + +log() { + local ts + ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + printf '%s %s\n' "$ts" "$*" +} + +guard_processes() { + if [[ -r "$conf" ]]; then + awk '/^[[:space:]]*(#|$)/ { next } { print }' "$conf" + else + printf '%s\n' "${default_processes[@]}" + fi +} + +# Prints the matching process name on stdout and returns 0 if any known +# meeting process is running; returns 1 (no output) otherwise. A pgrep +# failure for an individual name is treated as "not running", never fatal. +check_process() { + local name + while IFS= read -r name; do + [[ -n "$name" ]] || continue + if "$pgrep_cmd" -xq "$name" 2>/dev/null; then + printf '%s\n' "$name" + return 0 + fi + done < <(guard_processes) + return 1 +} + +# Returns 0 if any audio engine reports a running input stream. ioreg +# failure/unexpected output is treated as "not active", never fatal. +check_mic_active() { + "$ioreg_cmd" -c IOAudioEngine -r -l 2>/dev/null \ + | grep -q '"IOAudioEngineState" = "running"' +} + +main() { + local match + if match="$(check_process)"; then + log "action=suppress reason=process:$match" + return 0 + fi + if check_mic_active; then + log "action=suppress reason=mic-active" + return 0 + fi + "$pmset_cmd" displaysleepnow >/dev/null 2>&1 || true + log "action=sleep" +} + +main "$@" +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bats tests/lock-guard.bats` +Expected: all 3 tests PASS. + +- [ ] **Step 5: shellcheck** + +Run: `shellcheck -S info bin/lock-guard` +Expected: no findings. Fix any and re-run. + +- [ ] **Step 6: Commit** + +```bash +git add bin/lock-guard tests/lock-guard.bats +git commit -m "feat: add lock-guard with process-list and mic-active suppression checks" +``` + +--- + +## Task 2: Google Meet tab detection via `osascript` + +**Files:** + +- Modify: `bin/lock-guard` +- Modify: `tests/lock-guard.bats` + +**Interfaces:** + +- Consumes: `LOCK_SYNC_OSASCRIPT` env var (already wired into `setup()` in + Task 1's test file, unused until now), `check_process`, `check_mic_active`, + `log` from Task 1. +- Produces: `check_meet_tab_open` function, wired into `main`'s suppression + chain after the mic check. + +- [ ] **Step 1: Write the failing test** + +Add to `tests/lock-guard.bats`: + +```bash +make_osascript_stub_meet_tab() { + cat >"$STUB_DIR/osascript" <<'STUBEOF' +#!/bin/bash +echo "https://meet.google.com/abc-defg-hij" +STUBEOF + chmod +x "$STUB_DIR/osascript" +} + +@test "Meet tab open in Chrome: suppresses and does not call pmset" { + make_osascript_stub_meet_tab + run "$SCRIPT" + [ "$status" -eq 0 ] + [ "${#lines[@]}" -eq 1 ] + [[ "${lines[0]}" == *"action=suppress"* ]] + [[ "${lines[0]}" == *"reason=meet-tab-open"* ]] + [ ! -s "$PMSET_LOG" ] +} + +@test "Meet landing page open (no room code) does not suppress" { + cat >"$STUB_DIR/osascript" <<'STUBEOF' +#!/bin/bash +echo "https://meet.google.com/landing" +STUBEOF + chmod +x "$STUB_DIR/osascript" + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"action=sleep"* ]] + grep -q '^displaysleepnow$' "$PMSET_LOG" +} + +@test "osascript failure (e.g. missing Automation permission) does not suppress or crash" { + cat >"$STUB_DIR/osascript" <<'STUBEOF' +#!/bin/bash +exit 1 +STUBEOF + chmod +x "$STUB_DIR/osascript" + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"action=sleep"* ]] + grep -q '^displaysleepnow$' "$PMSET_LOG" +} +``` + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `bats tests/lock-guard.bats` +Expected: the 3 new tests FAIL (Meet tab match not implemented; landing-page +and osascript-failure cases currently pass trivially since nothing calls +osascript yet — confirm those two still assert real behavior once Step 3 +lands, they are guards against future regressions). + +- [ ] **Step 3: Add Meet tab check to `bin/lock-guard`** + +Add this function, and wire it into `main`: + +```bash +# Returns 0 if any open Chrome tab's URL matches an active Meet room +# (meet.google.com/xxx-yyyy-zzz), as opposed to the bare landing page. +# osascript failure (e.g. Automation permission not yet granted, or Chrome +# not running) is treated as "no match", never fatal — see design doc's +# "macOS Automation permission" section. +check_meet_tab_open() { + local urls + urls="$("$osascript_cmd" -e ' + tell application "Google Chrome" + set theURLs to {} + repeat with w in windows + repeat with t in tabs of w + set end of theURLs to URL of t + end repeat + end repeat + return theURLs + end tell + ' 2>/dev/null)" || return 1 + [[ "$urls" =~ meet\.google\.com/[a-z]{3}-[a-z]{4}-[a-z]{3} ]] +} +``` + +Update `main`: + +```bash +main() { + local match + if match="$(check_process)"; then + log "action=suppress reason=process:$match" + return 0 + fi + if check_mic_active; then + log "action=suppress reason=mic-active" + return 0 + fi + if check_meet_tab_open; then + log "action=suppress reason=meet-tab-open" + return 0 + fi + "$pmset_cmd" displaysleepnow >/dev/null 2>&1 || true + log "action=sleep" +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bats tests/lock-guard.bats` +Expected: all 6 tests PASS. + +- [ ] **Step 5: shellcheck** + +Run: `shellcheck -S info bin/lock-guard` +Expected: no findings. + +- [ ] **Step 6: Commit** + +```bash +git add bin/lock-guard tests/lock-guard.bats +git commit -m "feat: detect active Google Meet tabs as a lock-guard suppression signal" +``` + +--- + +## Task 3: guard-processes config file support + +**Files:** + +- Modify: `tests/lock-guard.bats` + +**Interfaces:** + +- Consumes: `guard_processes` function from Task 1 (already reads + `LOCK_SYNC_GUARD_PROCESSES` — this task is test-only, confirming the + behavior already implemented in Task 1 works end-to-end with a real file). + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/lock-guard.bats`: + +```bash +@test "custom guard-processes config replaces the default list" { + cat >"$TMPDIR/guard-processes" <<'EOF' +# lock-sync guard-processes + +Slack Huddle + +# zoom.us intentionally not listed here +EOF + export LOCK_SYNC_GUARD_PROCESSES="$TMPDIR/guard-processes" + make_pgrep_stub_match "Slack Huddle" + + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"reason=process:Slack Huddle"* ]] +} + +@test "custom guard-processes config: zoom.us not matched when absent from custom list" { + cat >"$TMPDIR/guard-processes" <<'EOF' +Slack Huddle +EOF + export LOCK_SYNC_GUARD_PROCESSES="$TMPDIR/guard-processes" + make_pgrep_stub_match "zoom.us" + + run "$SCRIPT" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"action=sleep"* ]] + grep -q '^displaysleepnow$' "$PMSET_LOG" +} +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `bats tests/lock-guard.bats` +Expected: all 8 tests PASS without further implementation changes — Task 1's +`guard_processes` function already implements this. If either test fails, +the failure is a real bug in Task 1's implementation; fix `bin/lock-guard` +before proceeding. + +- [ ] **Step 3: Commit** + +```bash +git add tests/lock-guard.bats +git commit -m "test: cover guard-processes config file override in lock-guard" +``` + +--- + +## Task 4: Wire `lock-guard` into `lock-fanout` + +**Files:** + +- Modify: `bin/lock-fanout:38` +- Modify: `tests/lock-fanout.bats` + +**Interfaces:** + +- Consumes: `bin/lock-guard` (Task 1-2's finished script) as the new SSH + remote command, replacing the literal `pmset displaysleepnow`. + +- [ ] **Step 1: Update the failing assertions in `tests/lock-fanout.bats`** + +The existing ssh stub in `tests/lock-fanout.bats` doesn't currently assert +which remote command ssh was given — it only checks `TARGET=user@host`. Add +one assertion to confirm the remote command changed. Modify +`make_ssh_stub`'s stub body to also capture the remote command: + +```bash +# Replace the existing make_ssh_stub function body with this version, which +# additionally records the remote command (the last non-flag, non-target arg) +# to a second log file for assertion. +make_ssh_stub() { + cat >"$STUB_DIR/ssh" <<'STUBEOF' +#!/bin/bash +has_n=0 +target="" +remote_cmd="" +for a in "$@"; do + case "$a" in + -n) has_n=1 ;; + -o|BatchMode=yes|ConnectTimeout=5|StrictHostKeyChecking=accept-new) ;; + *@*) target="$a" ;; + *) [[ -n "$target" ]] && remote_cmd="$a" ;; + esac +done +if (( has_n == 0 )); then + cat >/dev/null +fi +echo "TARGET=$target" >>"$SSH_LOG" +echo "REMOTE_CMD=$remote_cmd" >>"$SSH_LOG" +if [[ -n "${STUB_SSH_FAIL_HOST:-}" && "$target" == *"$STUB_SSH_FAIL_HOST"* ]]; then + exit 255 +fi +exit 0 +STUBEOF + chmod +x "$STUB_DIR/ssh" +} +``` + +Add a new test: + +```bash +@test "remote command is lock-guard, not a bare pmset call" { + make_list_clients_stub "asiago.local" + make_ssh_stub + + run "$SCRIPT" + [ "$status" -eq 0 ] + grep -q '^REMOTE_CMD=lock-guard$' "$SSH_LOG" +} +``` + +- [ ] **Step 2: Run tests to verify the new test fails** + +Run: `bats tests/lock-fanout.bats` +Expected: `remote command is lock-guard, not a bare pmset call` FAILS +(`REMOTE_CMD=pmset` currently). + +- [ ] **Step 3: Update `bin/lock-fanout`** + +In `fanout_host`, change: + +```bash + "$user@$host" pmset displaysleepnow >/dev/null 2>&1 || rc=$? +``` + +to: + +```bash + "$user@$host" lock-guard >/dev/null 2>&1 || rc=$? +``` + +- [ ] **Step 4: Run all lock-fanout tests to verify they pass** + +Run: `bats tests/lock-fanout.bats` +Expected: all tests PASS, including the pre-existing ones (the stub change +in Step 1 is additive and must not break the existing `TARGET=` assertions). + +- [ ] **Step 5: shellcheck** + +Run: `shellcheck -S info bin/lock-fanout` +Expected: no findings. + +- [ ] **Step 6: Commit** + +```bash +git add bin/lock-fanout tests/lock-fanout.bats +git commit -m "feat: fanout invokes lock-guard on clients instead of bare pmset" +``` + +--- + +## Task 5: Install/uninstall symlink `lock-guard` + +**Files:** + +- Modify: `bin/install` +- Modify: `bin/uninstall` +- Modify: `tests/install.bats` + +**Interfaces:** + +- Consumes: `bin/lock-guard` (must exist as a file in the repo — completed + in Task 1). + +- [ ] **Step 1: Read `bin/uninstall` to confirm how it enumerates symlinks** + +Run: `cat bin/uninstall` + +If it hardcodes a binary list (mirroring `install`'s `for b in lock-watcher +lock-fanout list-clients`), add `lock-guard` to it in Step 3 below. If it +instead globs `$bin_dir/*` or reads the plist, no change is needed there — +note that in the commit message. + +- [ ] **Step 2: Write the failing test** + +Add to `tests/install.bats`, inside the existing `"fresh install creates +symlinks, plist, and bootstraps via launchctl"` test, alongside the other +`[ -L ... ]` assertions: + +```bash + [ -L "$bin_dir/lock-guard" ] +``` + +Also add a dedicated uninstall assertion — find the existing uninstall test +(e.g. `"uninstall removes symlinks..."`) and add: + +```bash + [ ! -e "$bin_dir/lock-guard" ] +``` + +to its post-uninstall checks list. + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `bats tests/install.bats` +Expected: the new `[ -L "$bin_dir/lock-guard" ]` assertion FAILS. + +- [ ] **Step 4: Update `bin/install`** + +Change: + +```bash +for b in lock-watcher lock-fanout list-clients; do +``` + +to: + +```bash +for b in lock-watcher lock-fanout list-clients lock-guard; do +``` + +If Step 1 found a hardcoded list in `bin/uninstall`, apply the same addition +there. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `bats tests/install.bats` +Expected: all tests PASS. + +- [ ] **Step 6: shellcheck** + +Run: `shellcheck -S info bin/install bin/uninstall` +Expected: no findings. + +- [ ] **Step 7: Commit** + +```bash +git add bin/install bin/uninstall tests/install.bats +git commit -m "feat: install/uninstall lock-guard alongside the other lock-sync binaries" +``` + +--- + +## Task 6: Documentation + +**Files:** + +- Modify: `CLAUDE.md` +- Modify: `README.md` (if it exists and documents the binaries/config files + — check first) + +- [ ] **Step 1: Check for README.md** + +Run: `ls README.md 2>/dev/null && cat README.md` + +- [ ] **Step 2: Update `CLAUDE.md`** + +In the "Key external inputs" section, add a new bullet after the per-client +username override bullet: + +```markdown +- **Meeting-app suppression list:** `~/.config/lock-sync/guard-processes` (overridable via `LOCK_SYNC_GUARD_PROCESSES`). Same shape as the username-override config: plain text, one process name per line, `#` comments. Absent file uses the built-in default list (`zoom.us`, `Microsoft Teams`, `FaceTime`). Present file replaces the default list entirely (not merged). +``` + +In the "Commands" section, add a new bullet after the `lock-fanout` bullet: + +```markdown +- `bin/lock-guard` — runs on each client (invoked remotely by `lock-fanout` in place of a bare `pmset displaysleepnow`). Skips the lock and logs why if the client looks like it's in a call: a known meeting app is running (`~/.config/lock-sync/guard-processes`), the microphone is actively in use, or a Google Meet room tab is open in Chrome. Emits ` action=sleep` or ` action=suppress reason=`. +``` + +Add a new subsection after "Current state" documenting the one-time +Automation permission requirement: + +```markdown +## lock-guard: one-time Chrome Automation permission + +`lock-guard`'s Google Meet detection drives Chrome via `osascript`. The +first time this runs on a client, macOS prompts for Automation permission +(System Settings > Privacy & Security > Automation). Because `lock-guard` +runs non-interactively over SSH from launchd, there is no interactive +session to click "Allow" in — grant this manually once per client, by +running `bin/lock-guard` interactively at a local Terminal on that client +after install, approving the Chrome automation prompt when it appears. If +the permission is never granted, Meet-tab detection silently fails open +(treated as "no Meet tab found," never as a fatal error) — the process-list +and mic-active checks still work normally. +``` + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: document lock-guard, guard-processes config, and Chrome Automation setup" +``` + +--- + +## Self-review checklist (already applied above, kept for reference) + +- Spec coverage: process-list check (Task 1), mic-active check (Task 1), + Meet-tab check (Task 2), configurable process list (Task 3), fanout wiring + (Task 4), install/uninstall (Task 5), docs incl. one-time permission setup + (Task 6). All three signals from the design are implemented and tested + independently plus in combination via the "no signals" and priority-order + tests. +- No placeholders: every step has literal code or literal shell commands. +- Type/name consistency: `check_process`, `check_mic_active`, + `check_meet_tab_open`, `guard_processes`, `log` are used with the same + names across Tasks 1–2 as introduced. +