Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ Code-Notify uses the hook systems built into AI coding tools:

For Codex, Code-Notify configures `notify = ["/absolute/path/to/notifier.sh", "codex"]` and reads the JSON payload Codex appends on completion.
Codex currently exposes completion events through `notify`; approval and `request_permissions` prompts do not currently arrive through this hook.
On macOS, when a Codex session runs through T3 Code, Code-Notify uses the payload's `thread-id` to resolve the matching title from T3 Code's local state. If T3 Code is unavailable or no matching thread exists, it falls back to the workspace name.

For omp, Code-Notify writes a small managed extension module to `~/.omp/agent/extensions/code-notify.js`, because omp loads extension modules instead of reading hook commands from a config file. The extension forwards omp's `agent_end` event to the same `notifier.sh`, so sound, voice, Slack/Discord channels, click-through, the global mute switch, and rate limiting all work unchanged. Like Codex, omp currently exposes completion events; approval and idle prompts are not yet wired.

Expand Down
65 changes: 63 additions & 2 deletions lib/code-notify/core/notifier.sh
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,17 @@ get_codex_hook_type() {
}

get_codex_project_name() {
local payload_cwd
local payload_cwd thread_id t3_thread_title
thread_id=$(json_extract_string "$HOOK_DATA" "thread-id")

if [[ -n "$thread_id" ]]; then
t3_thread_title=$(get_t3_thread_title "$thread_id")
if [[ -n "$t3_thread_title" ]]; then
printf '%s\n' "$t3_thread_title"
return 0
fi
fi

payload_cwd=$(json_extract_string "$HOOK_DATA" "cwd")

if [[ -n "$payload_cwd" ]]; then
Expand All @@ -104,6 +114,53 @@ get_codex_project_name() {
fi
}

# Resolve a Codex thread to its T3 Code title when T3 local state is available.
get_t3_thread_title() {
local thread_id="$1"
local state_db="$HOME/.t3/userdata/state.sqlite"

[[ -n "$thread_id" ]] || return 1
[[ -f "$state_db" ]] || return 1
has_python3 || 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])
codex_thread_id = sys.argv[2]

try:
with sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1) as conn:
rows = conn.execute(
"""
select t.title, r.resume_cursor_json
from provider_session_runtime r
join projection_threads t using (thread_id)
where r.provider_name = 'codex'
"""
)
for title, resume_cursor_json in rows:
try:
resume_cursor = json.loads(resume_cursor_json or "{}")
except (TypeError, json.JSONDecodeError):
continue

if not isinstance(resume_cursor, dict):
continue

if resume_cursor.get("threadId") == codex_thread_id and isinstance(title, str):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] json.loads() may successfully return a list, string, number, or None, after which .get() raises AttributeError and aborts the entire resolver before a later valid matching row is examined. I reproduced this by replacing the unrelated fixture value not-json with []; tests/test-codex-notify.sh then fails with T3 thread title was not used. Please skip non-dictionary cursor values before calling .get() and extend the fixture to cover this case.

title = " ".join(title.split())

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Please preserve notification delivery for free-form T3 titles. A title containing " reaches SUBTITLE and is interpolated directly into the osascript -e source at line 617; for example, Investigate "missing" notifications produces AppleScript error -2740, and stderr is discarded, so users without terminal-notifier silently lose the notification. Please pass the values through AppleScript argv or otherwise escape them safely, and add a regression test covering quotes.

if title:
print(title, end="")
break
except (OSError, sqlite3.Error):
pass
PY
}

HOOK_DATA=""
if [[ "$RAW_ARG1" == "codex" ]]; then
TOOL_NAME="codex"
Expand Down Expand Up @@ -560,7 +617,11 @@ send_macos_notification() {
else
# osascript doesn't support click-to-activate, but we can use a workaround.
# Keep this silent too so custom/default sound playback stays single-sourced.
osascript -e "display notification \"$MESSAGE\" with title \"$TITLE\" subtitle \"$SUBTITLE\"" 2>/dev/null
osascript - "$MESSAGE" "$TITLE" "$SUBTITLE" <<'APPLESCRIPT' 2>/dev/null
on run argv
display notification (item 1 of argv) with title (item 2 of argv) subtitle (item 3 of argv)
end run
APPLESCRIPT
fi
}

Expand Down
88 changes: 81 additions & 7 deletions tests/test-codex-notify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,61 @@ with sqlite3.connect(db_path) as conn:
PY
}

write_t3_thread_metadata() {
local codex_thread_id="$1"
local title="$2"

python3 - "$HOME/.t3/userdata/state.sqlite" "$codex_thread_id" "$title" <<'PY'
import json
import pathlib
import sqlite3
import sys

db_path = pathlib.Path(sys.argv[1])
codex_thread_id = sys.argv[2]
title = sys.argv[3]
db_path.parent.mkdir(parents=True, exist_ok=True)

with sqlite3.connect(db_path) as conn:
conn.execute(
"create table projection_threads (thread_id text primary key, title text not null)"
)
conn.execute(
"""
create table provider_session_runtime (
thread_id text primary key,
provider_name text not null,
resume_cursor_json text
)
"""
)
conn.execute(
"insert into projection_threads values (?, ?)",
("malformed-runtime", "Wrong thread"),
)
conn.execute(
"insert into provider_session_runtime values (?, ?, ?)",
("malformed-runtime", "codex", "not-json"),
)
conn.execute(
"insert into projection_threads values (?, ?)",
("non-object-runtime", "Wrong thread"),
)
conn.execute(
"insert into provider_session_runtime values (?, ?, ?)",
("non-object-runtime", "codex", json.dumps([])),
)
conn.execute(
"insert into projection_threads values (?, ?)",
("matching-runtime", title),
)
conn.execute(
"insert into provider_session_runtime values (?, ?, ?)",
("matching-runtime", "codex", json.dumps({"threadId": codex_thread_id})),
)
PY
}

test_dir="$(mktemp -d)"
trap 'rm -rf "$test_dir"' EXIT

Expand Down Expand Up @@ -143,14 +198,33 @@ run_codex_notifier "$fake_path" '{"type":"agent-turn-complete","thread-id":"desk
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" 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"
write_t3_thread_metadata "t3-codex-thread" 'Investigate "missing" desktop notifications'
run_codex_notifier "$fake_path" '{"type":"agent-turn-complete","thread-id":"t3-codex-thread","cwd":"/tmp/demo","client":"codex-exec","last-assistant-message":"T3 event"}'

wait_for_lines "$notification_log" 4 || fail "expected four Codex notification deliveries"
wait_for_lines "$sound_log" 4 || fail "expected four Codex sound playbacks"
wait_for_lines "$HOME/.claude/logs/notifications.log" 4 || fail "expected four 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"
grep -Fq 'Task Complete - Investigate "missing" desktop notifications' "$notification_log" || fail "T3 thread title was not used as Codex notification context"
[[ $(wc -l < "$notification_log") -eq 4 ]] || fail "desktop-origin Codex events were not suppressed correctly"
[[ $(wc -l < "$sound_log") -eq 4 ]] || fail "desktop-origin Codex sound playback was not suppressed correctly"
[[ $(wc -l < "$HOME/.claude/logs/notifications.log") -eq 4 ]] || fail "desktop-origin Codex log entries were not suppressed correctly"

if [[ "$(uname -s)" == "Darwin" ]]; then
osascript_log="$log_dir/osascript.log"
rm "$fake_bin/terminal-notifier"
cat > "$fake_bin/osascript" <<EOF
#!/bin/bash
printf '%s\n' "\$@" > "$osascript_log"
EOF
chmod +x "$fake_bin/osascript"

run_codex_notifier "$fake_path" '{"type":"agent-turn-complete","thread-id":"t3-codex-thread","cwd":"/tmp/demo","client":"codex-exec","last-assistant-message":"T3 fallback event"}'

wait_for_lines "$osascript_log" 4 || fail "osascript fallback did not receive notification arguments"
grep -Fqx 'Task Complete - Investigate "missing" desktop notifications' "$osascript_log" || fail "osascript fallback did not preserve quotes in the T3 thread title"
fi

pass "Codex notifies for CLI sessions while suppressing desktop-origin duplicate events"
pass "Codex resolves T3 titles while preserving CLI fallback and desktop suppression"