From 3d7a1a00e6f1439132a6196e768088980cd24477 Mon Sep 17 00:00:00 2001 From: Jacky Wong <29943110+jw-12138@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:03:58 +0800 Subject: [PATCH 1/3] fix: suppress duplicate notifications when desktop app wraps CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add has_desktop_app_running() to detect when a tool's desktop app (e.g. Codex app) is running alongside its CLI - When detected, suppress code-notify since the desktop app sends its own notifications - macOS only — uses pgrep to check for .app bundle processes - Add CODE_NOTIFY_SKIP_DESKTOP_CHECK env var for test isolation --- lib/code-notify/core/notifier.sh | 24 ++++++++++++++++++++++++ tests/test-codex-notify.sh | 1 + 2 files changed, 25 insertions(+) diff --git a/lib/code-notify/core/notifier.sh b/lib/code-notify/core/notifier.sh index 59b958d..3a0decc 100755 --- a/lib/code-notify/core/notifier.sh +++ b/lib/code-notify/core/notifier.sh @@ -217,6 +217,25 @@ is_project_scoped_notification() { return 1 } +# Detect if the tool's desktop app (GUI) is running (macOS only). +# Desktop apps that wrap the CLI (e.g. Codex app) send their own notifications, +# so code-notify should suppress to avoid duplicates. +# Set CODE_NOTIFY_SKIP_DESKTOP_CHECK=1 to disable (used in tests). +has_desktop_app_running() { + [[ "$(uname -s)" != "Darwin" ]] && return 1 + [[ "${CODE_NOTIFY_SKIP_DESKTOP_CHECK:-}" == "1" ]] && return 1 + + local tool="$1" + case "$tool" in + "codex") + pgrep -f "[Cc]odex\.app" > /dev/null 2>&1 + ;; + *) + return 1 + ;; + esac +} + # Function to check if notification should be suppressed should_suppress_notification() { # Check kill switch first - instant disable without restart @@ -229,6 +248,11 @@ should_suppress_notification() { return 1 fi + # Suppress when the tool's desktop app (GUI) is running — it sends its own notifications + if has_desktop_app_running "$TOOL_NAME"; then + return 0 + fi + # Rate limit stop notifications to prevent spam from parallel sub-agents if [[ "$HOOK_TYPE" == "stop" ]]; then if is_rate_limited "last_stop_notification" "$STOP_RATE_LIMIT_SECONDS"; then diff --git a/tests/test-codex-notify.sh b/tests/test-codex-notify.sh index fb5f04d..184c5b0 100644 --- a/tests/test-codex-notify.sh +++ b/tests/test-codex-notify.sh @@ -28,6 +28,7 @@ run_codex_notifier() { PATH="$fake_path" \ CODE_NOTIFY_NOTIFICATION_RATE_LIMIT_SECONDS=180 \ + CODE_NOTIFY_SKIP_DESKTOP_CHECK=1 \ bash "$NOTIFIER" codex "$payload" } From 6aa4275602ef0dd9390e5b57b760d857a3723a0f Mon Sep 17 00:00:00 2001 From: Jacky Wong <29943110+jw-12138@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:25:33 +0800 Subject: [PATCH 2/3] fix: rename codex desktop check env var --- lib/code-notify/core/notifier.sh | 4 ++-- tests/test-codex-notify.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/code-notify/core/notifier.sh b/lib/code-notify/core/notifier.sh index 3a0decc..414ffa8 100755 --- a/lib/code-notify/core/notifier.sh +++ b/lib/code-notify/core/notifier.sh @@ -220,10 +220,10 @@ is_project_scoped_notification() { # Detect if the tool's desktop app (GUI) is running (macOS only). # Desktop apps that wrap the CLI (e.g. Codex app) send their own notifications, # so code-notify should suppress to avoid duplicates. -# Set CODE_NOTIFY_SKIP_DESKTOP_CHECK=1 to disable (used in tests). +# Set CODE_NOTIFY_SKIP_CODEX_DESKTOP_CHECK=1 to disable (used in tests). has_desktop_app_running() { [[ "$(uname -s)" != "Darwin" ]] && return 1 - [[ "${CODE_NOTIFY_SKIP_DESKTOP_CHECK:-}" == "1" ]] && return 1 + [[ "${CODE_NOTIFY_SKIP_CODEX_DESKTOP_CHECK:-}" == "1" ]] && return 1 local tool="$1" case "$tool" in diff --git a/tests/test-codex-notify.sh b/tests/test-codex-notify.sh index 184c5b0..606bc62 100644 --- a/tests/test-codex-notify.sh +++ b/tests/test-codex-notify.sh @@ -28,7 +28,7 @@ run_codex_notifier() { PATH="$fake_path" \ CODE_NOTIFY_NOTIFICATION_RATE_LIMIT_SECONDS=180 \ - CODE_NOTIFY_SKIP_DESKTOP_CHECK=1 \ + CODE_NOTIFY_SKIP_CODEX_DESKTOP_CHECK=1 \ bash "$NOTIFIER" codex "$payload" } From 567b8eda278e86abcaa19f14f2a59f3b09f5acbd Mon Sep 17 00:00:00 2001 From: Jacky Wong <29943110+jw-12138@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:50:25 +0800 Subject: [PATCH 3/3] fix: detect codex desktop notifications by trigger source --- lib/code-notify/core/notifier.sh | 93 +++++++++++++++++++++++++++----- tests/test-codex-notify.sh | 75 +++++++++++++++++++++++--- 2 files changed, 149 insertions(+), 19 deletions(-) diff --git a/lib/code-notify/core/notifier.sh b/lib/code-notify/core/notifier.sh index 414ffa8..a407065 100755 --- a/lib/code-notify/core/notifier.sh +++ b/lib/code-notify/core/notifier.sh @@ -217,23 +217,90 @@ is_project_scoped_notification() { return 1 } -# Detect if the tool's desktop app (GUI) is running (macOS only). -# Desktop apps that wrap the CLI (e.g. Codex app) send their own notifications, -# so code-notify should suppress to avoid duplicates. +# Find the newest Codex state database without hard-coding a schema version suffix. +get_latest_codex_state_db() { + local latest="" + local candidate + + for candidate in "$HOME/.codex"/state*.sqlite; do + [[ -e "$candidate" ]] || continue + if [[ -z "$latest" ]] || [[ "$candidate" -nt "$latest" ]]; then + latest="$candidate" + fi + done + + [[ -n "$latest" ]] || return 1 + printf '%s\n' "$latest" +} + +# Resolve the thread originator from Codex local state when the notify payload includes thread-id. +get_codex_thread_originator() { + local thread_id="$1" + local state_db + + [[ -n "$thread_id" ]] || return 1 + has_python3 || return 1 + + state_db=$(get_latest_codex_state_db) || return 1 + + python3 - "$state_db" "$thread_id" <<'PY' 2>/dev/null +import json +import pathlib +import sqlite3 +import sys + +db_path = pathlib.Path(sys.argv[1]) +thread_id = sys.argv[2] + +try: + with sqlite3.connect(db_path) as conn: + cur = conn.cursor() + cur.execute("select rollout_path from threads where id = ?", (thread_id,)) + row = cur.fetchone() +except Exception: + row = None + +if not row or not row[0]: + raise SystemExit(0) + +try: + first_line = pathlib.Path(row[0]).read_text(encoding="utf-8", errors="ignore").splitlines()[0] + payload = json.loads(first_line).get("payload", {}) + originator = payload.get("originator", "") +except Exception: + originator = "" + +if isinstance(originator, str): + print(originator, end="") +PY +} + +# Suppress only when this Codex event came from the desktop app itself. # Set CODE_NOTIFY_SKIP_CODEX_DESKTOP_CHECK=1 to disable (used in tests). -has_desktop_app_running() { - [[ "$(uname -s)" != "Darwin" ]] && return 1 +is_codex_desktop_trigger() { + [[ "$TOOL_NAME" != "codex" ]] && return 1 [[ "${CODE_NOTIFY_SKIP_CODEX_DESKTOP_CHECK:-}" == "1" ]] && return 1 - local tool="$1" - case "$tool" in - "codex") - pgrep -f "[Cc]odex\.app" > /dev/null 2>&1 + local client + client=$(json_extract_string "$HOOK_DATA" "client" | tr '[:upper:]' '[:lower:]') + case "$client" in + *app*|appserver) + return 0 ;; - *) - return 1 + esac + + local thread_id originator + thread_id=$(json_extract_string "$HOOK_DATA" "thread-id") + [[ -n "$thread_id" ]] || return 1 + + originator=$(get_codex_thread_originator "$thread_id") + case "$originator" in + "Codex Desktop") + return 0 ;; esac + + return 1 } # Function to check if notification should be suppressed @@ -248,8 +315,8 @@ should_suppress_notification() { return 1 fi - # Suppress when the tool's desktop app (GUI) is running — it sends its own notifications - if has_desktop_app_running "$TOOL_NAME"; then + # Suppress only when this Codex event originated from the desktop app. + if is_codex_desktop_trigger; then return 0 fi diff --git a/tests/test-codex-notify.sh b/tests/test-codex-notify.sh index 606bc62..9919911 100644 --- a/tests/test-codex-notify.sh +++ b/tests/test-codex-notify.sh @@ -27,11 +27,64 @@ run_codex_notifier() { local payload="$2" PATH="$fake_path" \ + CODE_NOTIFY_STOP_RATE_LIMIT_SECONDS=0 \ CODE_NOTIFY_NOTIFICATION_RATE_LIMIT_SECONDS=180 \ - CODE_NOTIFY_SKIP_CODEX_DESKTOP_CHECK=1 \ bash "$NOTIFIER" codex "$payload" } +write_codex_thread_metadata() { + local thread_id="$1" + local originator="$2" + local source="${3:-vscode}" + + python3 - "$HOME/.codex/state_5.sqlite" "$HOME/.codex/sessions" "$thread_id" "$originator" "$source" <<'PY' +import json +import pathlib +import sqlite3 +import sys + +db_path = pathlib.Path(sys.argv[1]) +sessions_dir = pathlib.Path(sys.argv[2]) +thread_id = sys.argv[3] +originator = sys.argv[4] +source = sys.argv[5] + +rollout_path = sessions_dir / f"{thread_id}.jsonl" +rollout_path.parent.mkdir(parents=True, exist_ok=True) +rollout_path.write_text( + json.dumps( + { + "type": "session_meta", + "payload": { + "id": thread_id, + "originator": originator, + "source": source, + }, + } + ) + + "\n", + encoding="utf-8", +) + +with sqlite3.connect(db_path) as conn: + cur = conn.cursor() + cur.execute( + """ + create table if not exists threads ( + id text primary key, + source text, + rollout_path text + ) + """ + ) + cur.execute( + "insert or replace into threads (id, source, rollout_path) values (?, ?, ?)", + (thread_id, source, str(rollout_path)), + ) + conn.commit() +PY +} + test_dir="$(mktemp -d)" trap 'rm -rf "$test_dir"' EXIT @@ -39,7 +92,7 @@ export HOME="$test_dir/home" fake_bin="$test_dir/bin" log_dir="$test_dir/log" sound_file="$test_dir/custom.aiff" -mkdir -p "$HOME/.claude/notifications" "$HOME/.claude/logs" "$fake_bin" "$log_dir" +mkdir -p "$HOME/.claude/notifications" "$HOME/.claude/logs" "$HOME/.codex" "$fake_bin" "$log_dir" touch "$sound_file" : > "$HOME/.claude/notifications/sound-enabled" @@ -82,12 +135,22 @@ fake_path="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin" run_codex_notifier "$fake_path" '{"type":"agent-turn-complete","cwd":"/tmp/demo","client":"codex-exec","input-messages":["Run tests"],"last-assistant-message":"All tests passed"}' run_codex_notifier "$fake_path" '{"type":"request_permissions","cwd":"/tmp/demo","tool":"exec_command"}' +run_codex_notifier "$fake_path" '{"type":"agent-turn-complete","cwd":"/tmp/demo","client":"codex-app","last-assistant-message":"Desktop event"}' + +write_codex_thread_metadata "desktop-thread" "Codex Desktop" +run_codex_notifier "$fake_path" '{"type":"agent-turn-complete","thread-id":"desktop-thread","cwd":"/tmp/demo","client":"codex-exec","last-assistant-message":"Desktop-backed event"}' + +write_codex_thread_metadata "cli-thread" "Codex CLI" "shell" +run_codex_notifier "$fake_path" '{"type":"agent-turn-complete","thread-id":"cli-thread","cwd":"/tmp/demo","client":"codex-exec","last-assistant-message":"CLI event still notifies"}' -wait_for_lines "$notification_log" 2 || fail "expected two Codex notification deliveries" -wait_for_lines "$sound_log" 2 || fail "expected two Codex sound playbacks" -wait_for_lines "$HOME/.claude/logs/notifications.log" 2 || fail "expected two Codex notification log entries" +wait_for_lines "$notification_log" 3 || fail "expected three Codex notification deliveries" +wait_for_lines "$sound_log" 3 || fail "expected three Codex sound playbacks" +wait_for_lines "$HOME/.claude/logs/notifications.log" 3 || fail "expected three Codex notification log entries" grep -q "Task Complete - demo" "$notification_log" || fail "Codex completion payload did not map to a stop notification" grep -q "Input Required - demo" "$notification_log" || fail "Codex permission-like payload did not map to an input-required notification" +[[ $(wc -l < "$notification_log") -eq 3 ]] || fail "desktop-origin Codex events were not suppressed correctly" +[[ $(wc -l < "$sound_log") -eq 3 ]] || fail "desktop-origin Codex sound playback was not suppressed correctly" +[[ $(wc -l < "$HOME/.claude/logs/notifications.log") -eq 3 ]] || fail "desktop-origin Codex log entries were not suppressed correctly" -pass "Codex payload parsing maps completion and permission-like payloads to the expected notification types" +pass "Codex notifies for CLI sessions while suppressing desktop-origin duplicate events"